├── .env.example
├── src
├── constants
│ └── index.ts
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── api
│ │ ├── update-servers
│ │ │ └── route.ts
│ │ ├── detect
│ │ │ └── route.ts
│ │ └── generate
│ │ │ └── route.ts
│ └── [locale]
│ │ ├── layout.tsx
│ │ └── page.tsx
├── config.ts
├── types
│ └── index.ts
├── navigation.ts
├── middleware.ts
├── types.ts
├── i18n
│ ├── request.ts
│ └── locales
│ │ ├── zh.json
│ │ └── en.json
├── scripts
│ └── tsconfig.json
├── components
│ ├── Modal.tsx
│ ├── LanguageSwitch.tsx
│ ├── Footer.tsx
│ ├── ModelFilter.tsx
│ ├── ModelTestModal.tsx
│ ├── ServiceList.tsx
│ └── Header.tsx
└── lib
│ ├── detect.ts
│ └── ollama-utils.ts
├── public
├── vercel.svg
├── window.svg
├── file.svg
├── globe.svg
└── next.svg
├── postcss.config.mjs
├── vercel.json
├── next.config.ts
├── eslint.config.mjs
├── .dockerignore
├── tailwind.config.ts
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── tsconfig.json
├── package.json
├── .github
└── workflows
│ └── monitor.yml
├── scripts
├── upload-data.ts
├── fofa-scan.mjs
└── monitor.ts
├── README.md
└── README.EN.md
/.env.example:
--------------------------------------------------------------------------------
1 | UPSTASH_REDIS_URL=
2 | UPSTASH_REDIS_TOKEN=
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/forrany/Awesome-Ollama-Server/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | export const locales = ['en', 'zh'] as const;
2 | export type Locale = typeof locales[number];
3 | export const defaultLocale: Locale = 'zh';
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "functions": {
3 | "src/app/api/detect/route.ts": {
4 | "maxDuration": 50
5 | },
6 | "src/app/api/update-servers/route.ts": {
7 | "maxDuration": 50
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface OllamaService {
2 | server: string;
3 | models: string[];
4 | tps: number;
5 | lastUpdate: string;
6 | }
7 |
8 | export type SortField = 'tps' | 'lastUpdate';
9 | export type SortOrder = 'asc' | 'desc';
--------------------------------------------------------------------------------
/src/navigation.ts:
--------------------------------------------------------------------------------
1 | import { createSharedPathnamesNavigation } from 'next-intl/navigation';
2 | import { locales } from './config';
3 |
4 | export const { Link, redirect, usePathname, useRouter } = createSharedPathnamesNavigation({
5 | locales,
6 | localePrefix: 'always'
7 | });
8 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import createNextIntlPlugin from 'next-intl/plugin';
2 | import type { NextConfig } from "next";
3 |
4 | const withNextIntl = createNextIntlPlugin();
5 |
6 | const nextConfig: NextConfig = {
7 | /* config options here */
8 | };
9 |
10 | export default withNextIntl(nextConfig);
11 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from 'next-intl/middleware';
2 | import { defaultLocale, locales } from './config';
3 |
4 | export default createMiddleware({
5 | defaultLocale,
6 | locales,
7 | localePrefix: 'always'
8 | });
9 |
10 | export const config = {
11 | matcher: ['/((?!api|_next|.*\\..*).*)']
12 | };
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface OllamaService {
2 | server: string;
3 | models: string[];
4 | tps: number;
5 | lastUpdate: string;
6 | loading?: boolean;
7 | status: 'success' | 'error' | 'loading' | 'fake';
8 | isFake?: boolean;
9 | }
10 |
11 | export type SortField = 'tps' | 'lastUpdate';
12 | export type SortOrder = 'asc' | 'desc';
--------------------------------------------------------------------------------
/src/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from 'next-intl/server';
2 | import { defaultLocale } from '../config';
3 |
4 | export default getRequestConfig(async ({ locale }) => {
5 | return {
6 | messages: (await import(`./locales/${locale || defaultLocale}.json`)).default,
7 | timeZone: 'UTC',
8 | now: new Date()
9 | };
10 | });
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/scripts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "CommonJS",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "outDir": "../../dist",
11 | "rootDir": ".."
12 | },
13 | "include": ["./**/*", "../lib/**/*"],
14 | "exclude": ["node_modules"]
15 | }
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Git
2 | .git
3 | .gitignore
4 | .github
5 |
6 | # 构建输出
7 | .next
8 | dist
9 | out
10 |
11 | # 依赖
12 | node_modules
13 |
14 | # 日志
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
19 | # 本地环境
20 | .env.local
21 | .env.development.local
22 | .env.test.local
23 | .env.production.local
24 |
25 | # 编辑器配置
26 | .idea
27 | .vscode
28 | *.swp
29 | *.swo
30 |
31 | # 其他
32 | .DS_Store
33 | *.pem
34 | .vercel
35 |
36 | # 调试工具
37 | .aider*
38 | .aider-desk
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: "var(--background)",
13 | foreground: "var(--foreground)",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | } satisfies Config;
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 | /dist
23 |
24 | # misc
25 | .DS_Store
26 | *.pem
27 |
28 | # debug
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 | .pnpm-debug.log*
33 |
34 | # env files (can opt-in for committing if needed)
35 | .env
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 | .aider*
44 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | ollama-monitor:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 | target: production
9 | ports:
10 | - "3000:3000"
11 | environment:
12 | - NODE_ENV=production
13 | - UPSTASH_REDIS_URL=${UPSTASH_REDIS_URL}
14 | - UPSTASH_REDIS_TOKEN=${UPSTASH_REDIS_TOKEN}
15 | restart: unless-stopped
16 |
17 | # 添加定期监控任务服务(可选)
18 | monitor-service:
19 | build:
20 | context: .
21 | dockerfile: Dockerfile
22 | target: production
23 | command: npm run monitor-and-upload
24 | environment:
25 | - NODE_ENV=production
26 | - UPSTASH_REDIS_URL=${UPSTASH_REDIS_URL}
27 | - UPSTASH_REDIS_TOKEN=${UPSTASH_REDIS_TOKEN}
28 | restart: unless-stopped
29 | depends_on:
30 | - ollama-monitor
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 使用Node.js官方镜像作为基础镜像
2 | FROM node:20-alpine AS base
3 |
4 | # 设置工作目录
5 | WORKDIR /app
6 |
7 | # 安装pnpm
8 | RUN npm install -g pnpm
9 |
10 | # 安装依赖
11 | COPY package.json package-lock.json* ./
12 | RUN npm ci
13 |
14 | # 复制所有文件
15 | COPY . .
16 |
17 | # 生成环境变量文件(如果不存在)
18 | RUN [ -f .env ] || cp .env.example .env
19 |
20 | # 构建应用
21 | RUN npm run build
22 |
23 | # 生产环境
24 | FROM node:20-alpine AS production
25 |
26 | WORKDIR /app
27 |
28 | # 复制依赖和构建文件
29 | COPY --from=base /app/node_modules ./node_modules
30 | COPY --from=base /app/.next ./.next
31 | COPY --from=base /app/public ./public
32 | COPY --from=base /app/package.json ./package.json
33 | COPY --from=base /app/.env ./.env
34 | COPY --from=base /app/next.config.ts ./next.config.ts
35 | COPY --from=base /app/scripts ./scripts
36 |
37 | # 暴露端口
38 | EXPOSE 3000
39 |
40 | # 启动应用
41 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "bundler",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true,
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ],
26 | "paths": {
27 | "@/*": [
28 | "./src/*"
29 | ]
30 | }
31 | },
32 | "include": [
33 | "next-env.d.ts",
34 | "src/**/*.ts",
35 | "src/**/*.tsx",
36 | ".next/types/**/*.ts",
37 | "scripts/monitor.ts"
38 | ],
39 | "exclude": [
40 | "node_modules"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer utilities {
6 | .custom-scrollbar::-webkit-scrollbar {
7 | width: 6px;
8 | height: 6px;
9 | }
10 |
11 | .custom-scrollbar::-webkit-scrollbar-track {
12 | background: rgba(75, 85, 99, 0.1);
13 | border-radius: 3px;
14 | }
15 |
16 | .custom-scrollbar::-webkit-scrollbar-thumb {
17 | background: rgba(75, 85, 99, 0.5);
18 | border-radius: 3px;
19 | }
20 |
21 | .custom-scrollbar::-webkit-scrollbar-thumb:hover {
22 | background: rgba(75, 85, 99, 0.7);
23 | }
24 | }
25 |
26 | :root {
27 | --background: #ffffff;
28 | --foreground: #171717;
29 | }
30 |
31 | @media (prefers-color-scheme: dark) {
32 | :root {
33 | --background: #0a0a0a;
34 | --foreground: #ededed;
35 | }
36 | }
37 |
38 | body {
39 | color: var(--foreground);
40 | background: var(--background);
41 | font-family: Arial, Helvetica, sans-serif;
42 | }
43 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/api/update-servers/route.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from '@upstash/redis'
2 | import { NextResponse } from 'next/server'
3 |
4 | const redis = new Redis({
5 | url: process.env.UPSTASH_REDIS_URL!,
6 | token: process.env.UPSTASH_REDIS_TOKEN!,
7 | })
8 |
9 | export async function POST(request: Request) {
10 | try {
11 | const { server } = await request.json()
12 | const encodedServer = encodeURIComponent(server)
13 |
14 | // 先检查服务器是否已存在
15 | const exists = await redis.sismember('ollama:servers', encodedServer)
16 | if (exists) {
17 | console.log(`服务器已存在: ${server}`)
18 | return NextResponse.json({ success: true, exists: true })
19 | }
20 |
21 | // 如果不存在,则添加
22 | await redis.sadd('ollama:servers', encodedServer)
23 | console.log(`新增服务器: ${server}`)
24 | return NextResponse.json({ success: true, exists: false })
25 | } catch (error) {
26 | console.error('更新服务器列表失败:', error)
27 | return NextResponse.json({ error: 'Failed to update servers' }, { status: 500 })
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ollama-monitor-service",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "upload": "ts-node scripts/upload-data.ts",
11 | "monitor": "tsx scripts/monitor.ts",
12 | "monitor-and-upload": "npm run monitor && npm run upload"
13 | },
14 | "dependencies": {
15 | "@headlessui/react": "^2.2.0",
16 | "@heroicons/react": "^2.2.0",
17 | "@types/axios": "^0.9.36",
18 | "axios": "^1.7.9",
19 | "date-fns": "^4.1.0",
20 | "next": "15.1.7",
21 | "next-intl": "^3.26.4",
22 | "react": "^19.0.0",
23 | "react-dom": "^19.0.0"
24 | },
25 | "devDependencies": {
26 | "@eslint/eslintrc": "^3",
27 | "@types/node": "^20",
28 | "@types/react": "^19",
29 | "@types/react-dom": "^19",
30 | "@upstash/redis": "^1.34.4",
31 | "dotenv": "^16.4.7",
32 | "eslint": "^9",
33 | "eslint-config-next": "15.1.7",
34 | "postcss": "^8",
35 | "tailwindcss": "^3.4.1",
36 | "ts-node": "^10.9.2",
37 | "tsx": "^4.19.3",
38 | "typescript": "^5"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.github/workflows/monitor.yml:
--------------------------------------------------------------------------------
1 | name: Update Services Monitor
2 |
3 | on:
4 | schedule:
5 | - cron: '0 */10 * * *' # 每10小时执行一次
6 | workflow_dispatch: # 允许手动触发
7 |
8 | jobs:
9 | update-services:
10 | runs-on: ubuntu-latest
11 |
12 | env:
13 | UPSTASH_REDIS_URL: ${{ secrets.UPSTASH_REDIS_URL }}
14 | UPSTASH_REDIS_TOKEN: ${{ secrets.UPSTASH_REDIS_TOKEN }}
15 | COUNTRYS: US,CN,JP,KR,SG,TW
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | with:
20 | fetch-depth: 0
21 |
22 | - name: Setup Node.js
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: '20'
26 |
27 | - name: Install dependencies
28 | run: npm install
29 |
30 | - name: Run update Services
31 | run: npm run monitor
32 |
33 | - name: Commit and push if changed
34 | run: |
35 | git config --global user.name 'GitHub Action'
36 | git config --global user.email 'action@github.com'
37 | git add public/data.json
38 | git diff --quiet && git diff --staged --quiet || (git commit -m "Update services data" && git push)
39 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/upload-data.ts:
--------------------------------------------------------------------------------
1 | const { Redis } = require('@upstash/redis')
2 | const fs = require('fs')
3 | const path = require('path')
4 | require('dotenv').config({ path: '.env' })
5 |
6 | const redis = new Redis({
7 | url: process.env.UPSTASH_REDIS_URL!,
8 | token: process.env.UPSTASH_REDIS_TOKEN!,
9 | })
10 |
11 | interface OllamaService {
12 | server: string
13 | models: string[]
14 | tps: number
15 | lastUpdate: string
16 | }
17 |
18 | async function uploadData() {
19 | try {
20 | // 读取data.json文件
21 | const dataPath = path.join(process.cwd(), 'public', 'data.json')
22 | const services: OllamaService[] = JSON.parse(fs.readFileSync(dataPath, 'utf8'))
23 |
24 | // 使用pipeline批量操作提高性能
25 | const pipeline = redis.pipeline()
26 |
27 | // 先清除已有数据
28 | pipeline.del('ollama:servers')
29 |
30 | // 将每个服务存储为hash
31 | for (const service of services) {
32 | const encodedServer = encodeURIComponent(service.server)
33 | // const key = `ollama:server:${encodedServer}`
34 | // pipeline.hset(key, {
35 | // models: JSON.stringify(service.models),
36 | // tps: service.tps,
37 | // lastUpdate: service.lastUpdate
38 | // })
39 | // 将编码后的server URL添加到服务器集合中
40 | pipeline.sadd('ollama:servers', encodedServer)
41 | }
42 |
43 | await pipeline.exec()
44 | console.log('数据上传成功!')
45 | } catch (error) {
46 | console.error('上传数据时出错:', error)
47 | }
48 | }
49 |
50 | uploadData()
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useEffect } from 'react';
2 | import { XMarkIcon } from '@heroicons/react/24/outline';
3 |
4 | interface ModalProps {
5 | isOpen: boolean;
6 | onClose: () => void;
7 | title: string;
8 | children: ReactNode;
9 | }
10 |
11 | export function Modal({ isOpen, onClose, title, children }: ModalProps) {
12 | // 处理 ESC 键关闭
13 | useEffect(() => {
14 | const handleEsc = (e: KeyboardEvent) => {
15 | if (e.key === 'Escape') onClose();
16 | };
17 |
18 | if (isOpen) {
19 | window.addEventListener('keydown', handleEsc);
20 | return () => window.removeEventListener('keydown', handleEsc);
21 | }
22 | }, [isOpen, onClose]);
23 |
24 | if (!isOpen) return null;
25 |
26 | return (
27 |
28 |
29 |
33 |
34 |
36 |
37 |
38 | {title}
39 |
40 |
46 |
47 |
48 |
49 | {children}
50 |
51 |
52 |
53 |
54 | );
55 | }
--------------------------------------------------------------------------------
/src/app/api/detect/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { checkService, measureTPS } from '@/lib/detect';
3 |
4 | export const maxDuration = 50; // 设置最大执行时间为 50 秒
5 |
6 | export async function POST(request: Request) {
7 | let url = '';
8 | try {
9 | const { url: requestUrl } = await request.json();
10 | url = requestUrl;
11 |
12 | if (!url) {
13 | return NextResponse.json({ error: 'URL is required' }, { status: 400 });
14 | }
15 |
16 | console.log('检测服务:', url);
17 |
18 | // 检查服务并获取可用模型
19 | const models = await checkService(url);
20 |
21 | // 如果服务不可用,返回空结果
22 | if (!models) {
23 | return NextResponse.json({
24 | server: url,
25 | models: [],
26 | tps: 0,
27 | lastUpdate: new Date().toISOString(),
28 | status: 'error'
29 | });
30 | }
31 |
32 | // 如果有可用模型,测试性能
33 | let tps = 0;
34 | let isFake = false;
35 |
36 | if (models.length > 0) {
37 | try {
38 | const tpsResult = await measureTPS(url, models[0]);
39 |
40 | // 检查是否为 fake-ollama
41 | if (typeof tpsResult === 'object' && 'isFake' in tpsResult) {
42 | isFake = true;
43 | tps = 0;
44 | } else {
45 | tps = tpsResult as number;
46 | }
47 | } catch (error) {
48 | console.error('性能测试失败:', error);
49 | }
50 | }
51 |
52 | // 返回结果
53 | return NextResponse.json({
54 | server: url,
55 | models: models.map(model => model.name),
56 | tps,
57 | lastUpdate: new Date().toISOString(),
58 | status: isFake ? 'fake' : 'success',
59 | isFake
60 | });
61 |
62 | } catch (error) {
63 | console.error('检测出错:', error);
64 | return NextResponse.json({
65 | server: url,
66 | models: [],
67 | tps: 0,
68 | lastUpdate: new Date().toISOString(),
69 | status: 'error'
70 | });
71 | }
72 | }
--------------------------------------------------------------------------------
/scripts/fofa-scan.mjs:
--------------------------------------------------------------------------------
1 | import { promises as fs } from 'fs';
2 | import axios from 'axios';
3 |
4 | export async function fofaScan(country = 'RU') {
5 | const searchQuery = `app="Ollama" && country="${country}"`;
6 | const encodedQuery = Buffer.from(searchQuery).toString('base64');
7 | const baseUrl = `https://fofa.info/result?qbase64=${encodedQuery}`;
8 | const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
9 |
10 | try {
11 | const response = await axios.get(baseUrl, {
12 | headers: {
13 | 'User-Agent': userAgent
14 | }
15 | });
16 |
17 | const HTML_START_TAG = 'hsxa-host"> console.log(host));
37 | console.log("已保存:result.txt", hosts.length, hosts);
38 |
39 | return { hosts };
40 |
41 | } catch (error) {
42 | const errorMessage = error.isAxiosError
43 | ? `请求失败: ${error.message}`
44 | : `未知错误: ${error}`;
45 |
46 | console.error(errorMessage);
47 | return {
48 | hosts: [],
49 | errorMessage
50 | };
51 | }
52 | }
53 |
54 | // 自执行异步函数
55 | (async () => {
56 | await fofaScan();
57 | })();
--------------------------------------------------------------------------------
/src/components/LanguageSwitch.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Locale, locales } from '@/config';
3 | import { GlobeAltIcon } from '@heroicons/react/24/outline';
4 | import { useRouter, usePathname } from '@/navigation';
5 |
6 | interface LanguageSwitchProps {
7 | currentLocale: Locale;
8 | }
9 |
10 | export function LanguageSwitch({ currentLocale }: LanguageSwitchProps) {
11 | const [isOpen, setIsOpen] = useState(false);
12 | const router = useRouter();
13 | const pathname = usePathname();
14 |
15 | const handleLanguageChange = (locale: Locale) => {
16 | setIsOpen(false);
17 | router.replace(pathname, { locale });
18 | };
19 |
20 | const languageNames: Record = {
21 | en: 'English',
22 | zh: '中文',
23 | };
24 |
25 | return (
26 |
27 |
35 |
36 | {isOpen && (
37 |
38 |
39 | {locales.map((locale) => (
40 |
52 | ))}
53 |
54 |
55 | )}
56 |
57 | );
58 | }
--------------------------------------------------------------------------------
/src/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { notFound } from 'next/navigation';
3 | import { NextIntlClientProvider } from 'next-intl';
4 | import { locales } from '@/config';
5 | import { Geist, Geist_Mono } from "next/font/google";
6 | import "../globals.css";
7 | import type { Metadata } from "next";
8 | import Script from 'next/script';
9 | import * as React from 'react'
10 | const geistSans = Geist({
11 | variable: "--font-geist-sans",
12 | subsets: ["latin"],
13 | });
14 |
15 | const geistMono = Geist_Mono({
16 | variable: "--font-geist-mono",
17 | subsets: ["latin"],
18 | });
19 |
20 | export const metadata: Metadata = {
21 | title: "Ollama Monitor",
22 | description: "Ollama Monitor",
23 | };
24 |
25 | interface RootLayoutProps {
26 | children: ReactNode;
27 | params: Promise<{
28 | locale: string;
29 | }>;
30 | }
31 |
32 | export function generateStaticParams() {
33 | return locales.map((locale) => ({ locale }));
34 | }
35 |
36 | async function getMessages(locale: string) {
37 | try {
38 | return (await import(`@/i18n/locales/${locale}.json`)).default;
39 | } catch (error) {
40 | notFound();
41 | console.error(error);
42 | }
43 | }
44 |
45 | export default async function RootLayout(props: RootLayoutProps) {
46 | const params = await props.params;
47 |
48 | const {
49 | children
50 | } = props;
51 |
52 | const messages = await getMessages(params.locale); // 异步获取 messages
53 | const now = new Date();
54 |
55 | return (
56 |
57 |
58 | {/* Google Analytics */}
59 |
63 |
71 |
72 |
78 | {children}
79 |
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/i18n/locales/zh.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Ollama 可用服务",
3 | "header": {
4 | "refresh": "刷新数据",
5 | "refreshing": "刷新中...",
6 | "refreshCountdown": "{countdown}秒后可刷新",
7 | "detect": "检测服务",
8 | "detecting": "检测中...",
9 | "detectCountdown": "{countdown}秒后可检测"
10 | },
11 | "detect": {
12 | "title": "检测 Ollama 服务",
13 | "description": "请输入要检测的 Ollama 服务地址,每行一个地址。\n例如:http://localhost:11434",
14 | "placeholder": "http://localhost:11434\nhttp://192.168.1.100:11434",
15 | "cancel": "取消",
16 | "confirm": "开始检测",
17 | "newDetection": "新的检测",
18 | "downloading": "正在下载数据...",
19 | "download": "下载检测结果",
20 | "error": "检测失败",
21 | "success": "检测成功",
22 | "unavailable": "服务不可用",
23 | "fake": "伪装服务"
24 | },
25 | "modelTest": {
26 | "title": "测试模型",
27 | "selectModel": "选择模型",
28 | "prompt": "输入提示词",
29 | "promptPlaceholder": "请输入您想要模型回答的问题或完成的任务...",
30 | "response": "模型回复",
31 | "responseEmpty": "等待生成...",
32 | "error": "生成失败,请重试",
33 | "close": "关闭",
34 | "test": "开始测试",
35 | "generating": "生成中...",
36 | "stop": "停止生成"
37 | },
38 | "filter": {
39 | "title": "模型过滤",
40 | "search": "搜索模型...",
41 | "selectedModels": "已选模型:",
42 | "clearSelection": "清空选择",
43 | "sort": {
44 | "tps": "TPS 排序",
45 | "lastUpdate": "更新时间排序"
46 | }
47 | },
48 | "service": {
49 | "server": "服务地址",
50 | "models": "可用模型",
51 | "tps": "{value} TPS",
52 | "lastUpdate": "更新时间",
53 | "lastUpdateValue": "更新于:{value}",
54 | "noServices": "暂无可用服务",
55 | "total": "共 {count} 个服务",
56 | "filtered": "已过滤模型: {models}",
57 | "availableModels": "可用模型:",
58 | "actions": "操作",
59 | "test": "测试"
60 | },
61 | "pagination": {
62 | "perPage": "每页显示:",
63 | "showing": "显示 {from} - {to} 条,共 {total} 条",
64 | "first": "首页",
65 | "prev": "上一页",
66 | "next": "下一页",
67 | "last": "末页"
68 | },
69 | "footer": {
70 | "about": {
71 | "title": "关于作者",
72 | "author": "作者:",
73 | "license": "本项目基于 MIT 协议开源,欢迎贡献和使用。"
74 | },
75 | "disclaimer": {
76 | "title": "免责声明",
77 | "items": [
78 | "1. 本网站仅用于安全研究和教育目的,展示的所有服务信息均来自互联网公开数据。",
79 | "2. 如果您发现自己的服务出现在列表中,建议及时加强网络安全防护措施。",
80 | "3. 本站不对任何因使用本站信息而导致的直接或间接损失负责。",
81 | "4. 请遵守当地法律法规,不得利用本站信息从事任何违法活动。"
82 | ]
83 | },
84 | "copyright": "© {year} Ollama Monitor Service. All rights reserved."
85 | }
86 | }
--------------------------------------------------------------------------------
/src/app/api/generate/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | export const maxDuration = 50; // 设置最大执行时间为 50 秒
4 |
5 | export async function POST(request: Request) {
6 | const encoder = new TextEncoder();
7 | const { server, model, prompt } = await request.json();
8 |
9 | try {
10 | const response = await fetch(`${server}/api/generate`, {
11 | method: 'POST',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | },
15 | body: JSON.stringify({
16 | model,
17 | prompt,
18 | stream: true,
19 | options: {
20 | temperature: 0.7,
21 | top_p: 0.9,
22 | }
23 | }),
24 | });
25 |
26 | if (!response.ok) {
27 | return NextResponse.json({ error: 'Generation failed' }, { status: response.status });
28 | }
29 |
30 | // 创建转换流来处理数据
31 | const transformStream = new TransformStream({
32 | async transform(chunk, controller) {
33 | try {
34 | // 将二进制数据转换为文本
35 | const text = new TextDecoder().decode(chunk);
36 | // 分割成行并过滤掉空行
37 | const lines = text.split('\n').filter(line => line.trim());
38 |
39 | for (const line of lines) {
40 | try {
41 | // 尝试解析每一行 JSON
42 | const data = JSON.parse(line.replace(/^data: /, ''));
43 | // 只处理包含 response 的数据
44 | if (data.response) {
45 | controller.enqueue(encoder.encode(data.response));
46 | }
47 | // 如果是最后一条消息,可以处理其他信息
48 | if (data.done) {
49 | break;
50 | }
51 | } catch (e) {
52 | // 如果解析失败,尝试直接发送内容
53 | if (line.includes('response')) {
54 | const match = line.match(/"response":"([^"]*?)"/);
55 | if (match && match[1]) {
56 | controller.enqueue(encoder.encode(match[1]));
57 | }
58 | }
59 | console.error('处理数据块错误:', e);
60 | continue;
61 | }
62 | }
63 | } catch (error) {
64 | console.error('处理数据块错误:', error);
65 | }
66 | }
67 | });
68 |
69 | // 将响应通过转换流处理
70 | const readableStream = response.body?.pipeThrough(transformStream);
71 | if (!readableStream) {
72 | return NextResponse.json({ error: 'No response body' }, { status: 500 });
73 | }
74 |
75 | return new Response(readableStream);
76 | } catch (error) {
77 | console.error('生成错误:', error);
78 | return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
79 | }
80 | }
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl';
2 |
3 | export function Footer() {
4 | const t = useTranslations();
5 | const disclaimerItems = t.raw('footer.disclaimer.items') as string[];
6 |
7 | return (
8 |
72 | );
73 | }
--------------------------------------------------------------------------------
/src/lib/detect.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TEST_PROMPTS,
3 | ModelInfo,
4 | fetchWithTimeout,
5 | checkService as checkServiceUtil,
6 | isFakeOllama,
7 | estimateTokens,
8 | generateRequestBody,
9 | isValidTPS
10 | } from './ollama-utils';
11 |
12 | const TEST_ROUNDS = 3; // 测试轮数
13 |
14 | // 导出 checkService 函数
15 | export const checkService = checkServiceUtil;
16 |
17 | // 测量服务性能
18 | export async function measureTPS(url: string, model: ModelInfo): Promise {
19 | try {
20 | let totalTokens = 0;
21 | let totalTime = 0;
22 | let isFake = false;
23 | let abnormalTpsDetected = false;
24 |
25 | // 多轮测试
26 | for (let i = 0; i < TEST_ROUNDS; i++) {
27 | const prompt = TEST_PROMPTS[i % TEST_PROMPTS.length];
28 |
29 | const response = await fetchWithTimeout(`${url}/api/generate`, {
30 | method: 'POST',
31 | headers: {
32 | 'Content-Type': 'application/json',
33 | },
34 | body: JSON.stringify(generateRequestBody(model.name, prompt, false)),
35 | });
36 |
37 | if (!response.ok) {
38 | console.error(`第 ${i + 1} 轮测试失败:`, await response.text());
39 | continue;
40 | }
41 |
42 | const data = await response.json();
43 |
44 | // 检查是否为 fake-ollama
45 | if (isFakeOllama(data.response)) {
46 | console.log(`检测到 fake-ollama: ${url}`);
47 | isFake = true;
48 | break;
49 | }
50 |
51 | // 使用 API 返回的 eval_count 和 eval_duration 计算 TPS
52 | if (data.eval_count && data.eval_duration) {
53 | const rawTps = (data.eval_count / data.eval_duration) * 1e9;
54 |
55 | // 检查 TPS 是否异常
56 | if (!isValidTPS(rawTps)) {
57 | console.warn(`检测到异常 TPS 值: ${rawTps.toFixed(2)} 来自服务器: ${url}`);
58 | abnormalTpsDetected = true;
59 | // 继续测试其他轮次,收集更多数据
60 | }
61 |
62 | totalTokens += data.eval_count;
63 | totalTime += data.eval_duration / 1e9; // 转换为秒
64 | } else {
65 | // 如果 API 没有返回这些字段,则使用估算方法
66 | const inputTokens = estimateTokens(prompt);
67 | const outputTokens = estimateTokens(data.response);
68 | const timeInSeconds = data.total_duration ? data.total_duration / 1e9 : 1; // 如果有 total_duration 则使用它
69 |
70 | totalTokens += (inputTokens + outputTokens);
71 | totalTime += timeInSeconds;
72 | }
73 |
74 | // 等待一小段时间再进行下一轮测试
75 | await new Promise(resolve => setTimeout(resolve, 1000));
76 | }
77 |
78 | // 如果是假的或者检测到异常 TPS,返回特殊标记
79 | if (isFake || abnormalTpsDetected) {
80 | return { isFake: true };
81 | }
82 |
83 | // 计算平均 TPS
84 | const averageTps = totalTime > 0 ? totalTokens / totalTime : 0;
85 |
86 | // 最后再检查一次平均 TPS 是否合理
87 | if (!isValidTPS(averageTps)) {
88 | console.warn(`最终计算的平均 TPS 异常: ${averageTps.toFixed(2)} 来自服务器: ${url}`);
89 | return { isFake: true };
90 | }
91 |
92 | return averageTps;
93 | } catch (error) {
94 | console.error('测量 TPS 失败:', error);
95 | return 0;
96 | }
97 | }
--------------------------------------------------------------------------------
/src/i18n/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Ollama Available Services",
3 | "header": {
4 | "refresh": "Refresh Data",
5 | "refreshing": "Refreshing...",
6 | "refreshCountdown": "Refresh in {countdown}s",
7 | "detect": "Detect Services",
8 | "detecting": "Detecting...",
9 | "detectCountdown": "Detect in {countdown}s"
10 | },
11 | "detect": {
12 | "title": "Detect Ollama Services",
13 | "description": "Please enter the Ollama service URLs to detect, one per line.\nExample: http://localhost:11434",
14 | "placeholder": "http://localhost:11434\nhttp://192.168.1.100:11434",
15 | "cancel": "Cancel",
16 | "confirm": "Start Detection",
17 | "newDetection": "New Detection",
18 | "downloading": "Downloading data...",
19 | "download": "Download Results",
20 | "error": "Detection Failed",
21 | "success": "Detection Success",
22 | "unavailable": "Service Unavailable",
23 | "fake": "Fake Service"
24 | },
25 | "modelTest": {
26 | "title": "Test Model",
27 | "selectModel": "Select Model",
28 | "prompt": "Enter Prompt",
29 | "promptPlaceholder": "Enter your question or task for the model...",
30 | "response": "Model Response",
31 | "responseEmpty": "Waiting for generation...",
32 | "error": "Generation failed, please try again",
33 | "close": "Close",
34 | "test": "Start Test",
35 | "generating": "Generating...",
36 | "stop": "Stop Generation"
37 | },
38 | "filter": {
39 | "title": "Model Filter",
40 | "search": "Search models...",
41 | "selectedModels": "Selected Models:",
42 | "clearSelection": "Clear Selection",
43 | "sort": {
44 | "tps": "Sort by TPS",
45 | "lastUpdate": "Sort by Update Time"
46 | }
47 | },
48 | "service": {
49 | "server": "Server",
50 | "models": "Available Models",
51 | "tps": "{value} TPS",
52 | "lastUpdate": "Last Update",
53 | "lastUpdateValue": "Updated: {value}",
54 | "noServices": "No services available",
55 | "total": "{count} Services Total",
56 | "actions": "Actions",
57 | "test": "Test",
58 | "filtered": "Filtered Models: {models}",
59 | "availableModels": "Available Models:"
60 | },
61 | "pagination": {
62 | "perPage": "Per page:",
63 | "showing": "Showing {from} - {to} of {total}",
64 | "first": "First",
65 | "prev": "Previous",
66 | "next": "Next",
67 | "last": "Last"
68 | },
69 | "footer": {
70 | "about": {
71 | "title": "About Author",
72 | "author": "Author:",
73 | "license": "This project is open source under the MIT License. Contributions are welcome."
74 | },
75 | "disclaimer": {
76 | "title": "Disclaimer",
77 | "items": [
78 | "1. This website is for security research and educational purposes only. All service information is from public data.",
79 | "2. If you find your service listed here, it's recommended to enhance your network security measures.",
80 | "3. We are not responsible for any direct or indirect losses caused by using the information on this site.",
81 | "4. Please comply with local laws and regulations. Do not use this information for any illegal activities."
82 | ]
83 | },
84 | "copyright": "© {year} Ollama Monitor Service. All rights reserved."
85 | }
86 | }
--------------------------------------------------------------------------------
/src/lib/ollama-utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Ollama 工具函数库
3 | * 提供与 Ollama API 交互的通用函数
4 | */
5 |
6 | // 超时设置
7 | export const TIMEOUT_MS = 30000; // 30秒超时
8 |
9 | // 测试提示词
10 | export const TEST_PROMPTS = [
11 | "Tell me a short story about a robot who learns to love.",
12 | "Explain the concept of recursion in programming.",
13 | "What are the main differences between classical and quantum computing?"
14 | ];
15 |
16 | // 定义模型信息的接口
17 | export interface ModelInfo {
18 | name: string;
19 | model: string;
20 | modified_at: string;
21 | size: number;
22 | digest: string;
23 | details: {
24 | parent_model: string;
25 | format: string;
26 | family: string;
27 | families: string[];
28 | parameter_size: string;
29 | quantization_level: string;
30 | };
31 | }
32 |
33 | // 创建带超时的 fetch 函数
34 | export async function fetchWithTimeout(url: string, options: RequestInit = {}, timeout = TIMEOUT_MS) {
35 | const controller = new AbortController();
36 | const timeoutId = setTimeout(() => controller.abort(), timeout);
37 |
38 | try {
39 | const response = await fetch(url, {
40 | ...options,
41 | signal: controller.signal,
42 | });
43 | clearTimeout(timeoutId);
44 | return response;
45 | } catch (error) {
46 | clearTimeout(timeoutId);
47 | throw error;
48 | }
49 | }
50 |
51 | // 检查服务可用性并获取模型列表
52 | export async function checkService(url: string): Promise {
53 | try {
54 | const response = await fetchWithTimeout(`${url}/api/tags`, {
55 | method: 'GET',
56 | headers: {
57 | 'Accept': 'application/json',
58 | },
59 | });
60 |
61 | if (!response.ok) {
62 | console.log(`服务返回非 200 状态码: ${url}, 状态码: ${response.status}`);
63 | return null;
64 | }
65 |
66 | const data = await response.json();
67 | return data.models || [];
68 | } catch (error) {
69 | console.error('检查服务失败:', error);
70 | return null;
71 | }
72 | }
73 |
74 | // 检测 TPS 是否在合理范围内
75 | export function isValidTPS(tps: number): boolean {
76 | // 正常的 Ollama 服务 TPS 通常在 0.1 到 100 之间
77 | // 高性能服务器可能达到 200-300 TPS
78 | // 超过 1000 的 TPS 值通常是不合理的
79 | const MIN_VALID_TPS = 0.01; // 最小有效 TPS
80 | const MAX_VALID_TPS = 1000; // 最大有效 TPS
81 |
82 | return tps >= MIN_VALID_TPS && tps <= MAX_VALID_TPS;
83 | }
84 |
85 | // 检测是否为 fake-ollama
86 | export function isFakeOllama(response: string): boolean {
87 | return response.includes('fake-ollama') ||
88 | response.includes('这是一条来自') ||
89 | response.includes('固定回复');
90 | }
91 |
92 | // 估算文本的 token 数量
93 | export function estimateTokens(text: string): number {
94 | // 这是一个简单的估算,实际的 token 数量可能会有所不同
95 | // 1. 按空格分词
96 | const words = text.split(/\s+/);
97 | // 2. 考虑标点符号
98 | const punctuation = text.match(/[.,!?;:'"()\[\]{}]/g)?.length || 0;
99 | // 3. 考虑数字
100 | const numbers = text.match(/\d+/g)?.length || 0;
101 |
102 | return words.length + punctuation + numbers;
103 | }
104 |
105 | // 生成测试请求体
106 | export function generateRequestBody(model: string, prompt: string, stream = false) {
107 | return {
108 | model,
109 | prompt,
110 | stream,
111 | options: {
112 | temperature: 0.7,
113 | top_p: 0.9,
114 | top_k: 40,
115 | }
116 | };
117 | }
118 |
119 | // 计算 TPS (Tokens Per Second)
120 | export function calculateTPS(data: { eval_count: number, eval_duration: number }): number {
121 | // 使用 API 返回的 eval_count 和 eval_duration 计算 TPS
122 | if (data.eval_count && data.eval_duration) {
123 | // eval_duration 是纳秒单位,计算: eval_count / eval_duration * 10^9
124 | const tps = (data.eval_count / data.eval_duration) * 1e9;
125 |
126 | // 检查 TPS 是否在合理范围内
127 | if (!isValidTPS(tps)) {
128 | console.warn(`检测到异常 TPS 值: ${tps.toFixed(2)}`);
129 | // 如果 TPS 不合理,返回一个合理的默认值
130 | return 0;
131 | }
132 |
133 | return tps;
134 | }
135 |
136 | // 如果没有这些字段,返回 0
137 | return 0;
138 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ollama 服务监控系统
2 |
3 | [English Version](https://github.com/forrany/Awesome-Ollama-Server/blob/main/README.EN.md)
4 |
5 | 这是一个用于监控和检测 Ollama 服务可用性和性能的系统。它提供了一个现代化的 Web 界面,支持多语言(中文/英文),并具有实时检测和数据展示功能。
6 |
7 | [在线体验](https://ollama.vincentko.top/)
8 |
9 | ---
10 |
11 | ### ❤️ Sponsors
12 | CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
13 |
14 | [
](https://edgeone.ai/?from=github)
15 |
16 | ---
17 |
18 | ## 功能特点
19 |
20 | - 🔍 服务检测
21 | - 支持批量检测 Ollama 服务
22 | - 实时显示检测状态和结果
23 | - 支持检测结果导出
24 | - 支持自动 FOFA 扫描
25 | - 📊 性能监控
26 | - 测试服务响应时间和 TPS
27 | - 展示可用模型列表
28 | - 性能数据可视化
29 | - 🌐 多语言支持
30 | - 中文界面
31 | - 英文界面
32 | - 一键切换语言
33 | - 🎯 高级筛选
34 | - 模型过滤
35 | - TPS/更新时间排序
36 | - 分页显示
37 |
38 | ## 技术栈
39 |
40 | - ⚡️ Next.js 14 - React 框架
41 | - 🔥 TypeScript - 类型安全
42 | - 🎨 Tailwind CSS - 样式框架
43 | - 🌍 next-intl - 国际化
44 | - 🔄 Server Components - 服务端组件
45 | - 📱 响应式设计 - 移动端适配
46 |
47 | ## 快速开始
48 |
49 | ### 前置要求
50 |
51 | - Node.js 18.0 或更高版本
52 | - npm 或 yarn 包管理器
53 |
54 | ### 安装
55 |
56 | ```bash
57 | # 克隆项目
58 | git clone https://github.com/forrany/Awesome-Ollama-Server.git
59 | cd Awesome-Ollama-Server
60 |
61 | # 安装依赖
62 | npm install
63 | # 或
64 | yarn install
65 | ```
66 |
67 | ### 开发环境
68 |
69 | ```bash
70 | # 启动开发服务器
71 | npm run dev
72 | # 或
73 | yarn dev
74 | ```
75 |
76 | 访问 [http://localhost:3000](http://localhost:3000/) 查看应用。
77 |
78 | ### 生产环境
79 |
80 | ```bash
81 | # 构建项目
82 | npm run build
83 | # 或
84 | yarn build
85 |
86 | # 启动服务
87 | npm start
88 | # 或
89 | yarn start
90 | ```
91 |
92 | ## 使用说明
93 |
94 | ### 检测服务
95 |
96 | 1. 点击"检测服务"按钮
97 | 2. 在弹出的对话框中输入 Ollama 服务地址(每行一个)
98 | 3. 点击"开始检测"
99 | 4. 等待检测完成,查看结果
100 | 5. 可选择下载检测结果
101 |
102 | ### 筛选和排序
103 |
104 | - 使用模型过滤器选择特定模型
105 | - 点击 TPS 或更新时间进行排序
106 | - 使用搜索框快速查找模型
107 |
108 | ### 语言切换
109 |
110 | - 点击右上角的语言切换按钮
111 | - 选择中文或英文
112 |
113 | ## 项目结构
114 |
115 | ```
116 | src/
117 | ├── app/ # Next.js 应用目录
118 | ├── components/ # React 组件
119 | ├── i18n/ # 国际化文件
120 | ├── lib/ # 工具函数
121 | ├── types/ # TypeScript 类型定义
122 | └── config/ # 配置文件
123 | ```
124 |
125 | ## 环境变量
126 |
127 | 创建 `.env` 文件并设置以下变量,填写后 Github Actions 会自动执行监控和上传
128 |
129 | ```
130 | # 可选:Redis 配置(如果使用)
131 | UPSTASH_REDIS_URL=your-redis-url
132 | UPSTASH_REDIS_TOKEN=your-redis-token
133 |
134 | # 可选:FOFA扫描国家列表(如果使用)
135 | COUNTRYS=US,CN,RU
136 | ```
137 |
138 | ## 贡献指南
139 |
140 | 1. Fork 项目
141 | 2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
142 | 3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
143 | 4. 推送到分支 (`git push origin feature/AmazingFeature`)
144 | 5. 打开 Pull Request
145 |
146 | ## 许可证
147 |
148 | 本项目基于 MIT 协议开源 - 详见 [LICENSE](https://github.com/forrany/Awesome-Ollama-Server/blob/main/LICENSE) 文件
149 |
150 | ## 作者
151 |
152 | VincentKo (@forrany) - [GitHub](https://github.com/forrany)
153 |
154 | ## 免责声明
155 |
156 | 1. 本项目仅用于安全研究和教育目的
157 | 2. 不得将本项目用于任何非法用途
158 | 3. 作者不对使用本项目造成的任何损失负责
159 | 4. 数据来源于网络,如有侵权,请联系作者删除
160 |
161 | ## Star History
162 |
163 | [](https://star-history.com/#forrany/Awesome-Ollama-Server&Date)
164 |
165 | ## Docker 部署
166 |
167 | 项目支持 Docker 部署,方便在各种环境中快速搭建。
168 |
169 | ### 使用 Docker Compose 部署(推荐)
170 |
171 | 1. 确保已安装 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/)
172 |
173 | 2. 克隆仓库并进入项目目录
174 |
175 | ```bash
176 | git clone https://github.com/forrany/Awesome-Ollama-Server.git
177 | cd Awesome-Ollama-Server
178 | ```
179 |
180 | 3. 创建环境变量文件(如果需要 Upstash Redis 数据存储)
181 |
182 | ```bash
183 | cp .env.example .env
184 | ```
185 |
186 | 然后编辑 `.env` 文件,填入 Upstash Redis 的凭据:
187 |
188 | ```
189 | UPSTASH_REDIS_URL=your_redis_url
190 | UPSTASH_REDIS_TOKEN=your_redis_token
191 | ```
192 |
193 | 4. 启动服务
194 |
195 | ```bash
196 | docker-compose up -d
197 | ```
198 |
199 | 这将启动两个服务:
200 | - `ollama-monitor`: Web 应用,访问 http://localhost:3000 查看
201 | - `monitor-service`: 后台监控服务,自动收集 Ollama 服务数据
202 |
203 | ### 仅使用 Docker 部署
204 |
205 | 如果只需要部署 Web 应用而不需要后台监控服务:
206 |
207 | ```bash
208 | # 构建镜像
209 | docker build -t ollama-monitor .
210 |
211 | # 运行容器
212 | docker run -d -p 3000:3000 --name ollama-monitor \
213 | -e UPSTASH_REDIS_URL=your_redis_url \
214 | -e UPSTASH_REDIS_TOKEN=your_redis_token \
215 | ollama-monitor
216 | ```
217 |
218 | 访问 [http://localhost:3000](http://localhost:3000/) 查看应用。
219 |
--------------------------------------------------------------------------------
/src/components/ModelFilter.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl';
2 | import { ArrowUpIcon, ArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
3 |
4 | interface ModelFilterProps {
5 | selectedModels: string[];
6 | availableModels: string[];
7 | searchTerm: string;
8 | sortField: 'tps' | 'lastUpdate';
9 | sortOrder: 'asc' | 'desc';
10 | onSearchChange: (term: string) => void;
11 | onToggleModel: (model: string) => void;
12 | onRemoveModel: (model: string) => void;
13 | onClearModels: () => void;
14 | onToggleSort: (field: 'tps' | 'lastUpdate') => void;
15 | }
16 |
17 | export function ModelFilter({
18 | selectedModels,
19 | availableModels,
20 | searchTerm,
21 | sortField,
22 | sortOrder,
23 | onSearchChange,
24 | onToggleModel,
25 | onRemoveModel,
26 | onClearModels,
27 | onToggleSort,
28 | }: ModelFilterProps) {
29 | const t = useTranslations();
30 |
31 | // 获取过滤后的模型列表
32 | const filteredModels = availableModels.filter(model =>
33 | model.toLowerCase().includes(searchTerm.toLowerCase())
34 | );
35 |
36 | // 排序图标组件
37 | const SortIcon = ({ field }: { field: 'tps' | 'lastUpdate' }) => {
38 | if (sortField !== field) return null;
39 | return sortOrder === 'asc' ?
40 | :
41 | ;
42 | };
43 |
44 | return (
45 |
46 |
47 |
48 |
{t('filter.title')}
49 |
50 |
59 |
68 |
69 |
70 |
71 | {/* 搜索输入框 */}
72 |
73 | onSearchChange(e.target.value)}
77 | placeholder={t('filter.search')}
78 | className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-200
79 | placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
80 | />
81 |
82 |
83 | {/* 已选模型 */}
84 | {selectedModels.length > 0 && (
85 |
86 |
87 | {t('filter.selectedModels')}
88 |
94 |
95 |
96 | {selectedModels.map(model => (
97 |
102 | {model}
103 |
109 |
110 | ))}
111 |
112 |
113 | )}
114 |
115 | {/* 模型列表 */}
116 |
117 | {filteredModels.map(model => (
118 |
129 | ))}
130 |
131 |
132 |
133 | );
134 | }
--------------------------------------------------------------------------------
/README.EN.md:
--------------------------------------------------------------------------------
1 | # Ollama Service Monitoring System
2 |
3 | [中文版](README.md)
4 |
5 | This is a system for monitoring and detecting the availability and performance of Ollama services. It provides a modern web interface, supports multiple languages (Chinese/English), and features real-time detection and data visualization.
6 |
7 | [Online Demo](https://ollama.vincentko.top)
8 |
9 | Support online model testing
10 |
14 |
15 | ## Features
16 |
17 | - 🔍 Service Detection
18 | - Supports batch detection of Ollama services
19 | - Real-time display of detection status and results
20 | - Supports exporting detection results
21 | - Supports automatic FOFA scanning
22 |
23 | - 📊 Performance Monitoring
24 | - Tests service response time and TPS
25 | - Displays a list of available models
26 | - Visualizes performance data
27 |
28 | - 🌐 Multi-language Support
29 | - Chinese interface
30 | - English interface
31 | - One-click language switching
32 |
33 | - 🎯 Advanced Filtering
34 | - Model filtering
35 | - TPS/Update Time sorting
36 | - Paginated display
37 |
38 | ## Technology Stack
39 |
40 | - ⚡️ Next.js 14 - React Framework
41 | - 🔥 TypeScript - Type Safety
42 | - 🎨 Tailwind CSS - CSS Framework
43 | - 🌍 next-intl - Internationalization
44 | - 🔄 Server Components - Server Components
45 | - 📱 Responsive Design - Mobile Adaptation
46 |
47 | ## Quick Start
48 |
49 | ### Prerequisites
50 |
51 | - Node.js 18.0 or higher
52 | - npm or yarn package manager
53 |
54 | ### Installation
55 |
56 | ```bash
57 | # Clone the project
58 | git clone git@github.com:forrany/Awesome-Ollama-Server.git
59 | cd Awesome-Ollama-Server
60 |
61 | # Install dependencies
62 | npm install
63 | # or
64 | yarn install
65 | ```
66 |
67 | ### Development Environment
68 |
69 | ```bash
70 | # Start the development server
71 | npm run dev
72 | # or
73 | yarn dev
74 | ```
75 |
76 | Visit http://localhost:3000 to view the application.
77 |
78 | ### Production Environment
79 |
80 | ```bash
81 | # Build the project
82 | npm run build
83 | # or
84 | yarn build
85 |
86 | # Start the server
87 | npm start
88 | # or
89 | yarn start
90 | ```
91 |
92 | ## Usage Instructions
93 |
94 | ### Detect Services
95 |
96 | 1. Click the "Service Detection" button
97 | 2. Enter Ollama service addresses in the pop-up dialog (one per line)
98 | 3. Click "Start Detection"
99 | 4. Wait for the detection to complete and view the results
100 | 5. Option to download detection results
101 |
102 | ### Filter and Sort
103 |
104 | - Use the model filter to select specific models
105 | - Click TPS or Update Time to sort
106 | - Use the search box to quickly find models
107 |
108 | ### Language Switching
109 |
110 | - Click the language switch button in the upper right corner
111 | - Select Chinese or English
112 |
113 | ## Project Structure
114 |
115 | ```
116 | src/
117 | ├── app/ # Next.js Application Directory
118 | ├── components/ # React Components
119 | ├── i18n/ # Internationalization Files
120 | ├── lib/ # Utility Functions
121 | ├── types/ # TypeScript Type Definitions
122 | └── config/ # Configuration Files
123 | ```
124 |
125 | ## Environment Variables
126 |
127 | Create a `.env` file and set the following variables. Github Actions will automatically execute monitoring and upload after filling in.
128 |
129 | ```env
130 | # Optional: Redis Configuration (if used)
131 | UPSTASH_REDIS_URL=your-redis-url
132 | UPSTASH_REDIS_TOKEN=your-redis-token
133 |
134 | # Optional: FOFA scanning country list (if used)
135 | COUNTRYS=US,CN,RU
136 | ```
137 |
138 | ## Docker Deployment
139 |
140 | This project supports Docker deployment for easy setup in various environments.
141 |
142 | ### Using Docker Compose (Recommended)
143 |
144 | 1. Make sure you have [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed
145 |
146 | 2. Clone the repository and navigate to the project directory
147 | ```bash
148 | git clone https://github.com/vincexiv/ollama-monitor-service.git
149 | cd ollama-monitor-service
150 | ```
151 |
152 | 3. Create an environment variables file (if you need Upstash Redis for data storage)
153 | ```bash
154 | cp .env.example .env
155 | ```
156 |
157 | Then edit the `.env` file and fill in your Upstash Redis credentials:
158 | ```
159 | UPSTASH_REDIS_URL=your_redis_url
160 | UPSTASH_REDIS_TOKEN=your_redis_token
161 | ```
162 |
163 | 4. Start the services
164 | ```bash
165 | docker-compose up -d
166 | ```
167 |
168 | This will start two services:
169 | - `ollama-monitor`: Web application, accessible at http://localhost:3000
170 | - `monitor-service`: Background monitoring service that automatically collects Ollama service data
171 |
172 | ### Using Docker Only
173 |
174 | If you only need to deploy the web application without the background monitoring service:
175 |
176 | ```bash
177 | # Build the image
178 | docker build -t ollama-monitor .
179 |
180 | # Run the container
181 | docker run -d -p 3000:3000 --name ollama-monitor \
182 | -e UPSTASH_REDIS_URL=your_redis_url \
183 | -e UPSTASH_REDIS_TOKEN=your_redis_token \
184 | ollama-monitor
185 | ```
186 |
187 | Access the application at http://localhost:3000.
188 |
189 | ## Contribution Guidelines
190 |
191 | 1. Fork the project
192 | 2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
193 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
194 | 4. Push to the branch (`git push origin feature/AmazingFeature`)
195 | 5. Open a Pull Request
196 |
197 | ## License
198 |
199 | This project is open-sourced under the MIT License - see the [LICENSE](LICENSE) file for details.
200 |
201 | ## Author
202 |
203 | VincentKo (@forrany) - [GitHub](https://github.com/forrany)
204 |
205 | ## Disclaimer
206 |
207 | 1. This project is for security research and educational purposes only.
208 | 2. This project must not be used for any illegal purposes.
209 | 3. The author is not responsible for any loss caused by the use of this project.
210 | 4. Data is sourced from the internet. If there is any infringement, please contact the author to delete it.
211 |
212 |
213 | ## Star History
214 |
215 | [](https://star-history.com/#forrany/Awesome-Ollama-Server&Date)
216 |
--------------------------------------------------------------------------------
/src/components/ModelTestModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef } from 'react';
2 | import { useTranslations } from 'next-intl';
3 | import { Modal } from './Modal';
4 | import { BeakerIcon, StopIcon } from '@heroicons/react/24/outline';
5 |
6 | interface ModelTestModalProps {
7 | isOpen: boolean;
8 | onClose: () => void;
9 | server: string;
10 | models: string[];
11 | }
12 |
13 | export function ModelTestModal({ isOpen, onClose, server, models }: ModelTestModalProps) {
14 | const t = useTranslations();
15 | const [selectedModel, setSelectedModel] = useState(models[0] || '');
16 | const [prompt, setPrompt] = useState('');
17 | const [response, setResponse] = useState('');
18 | const [isGenerating, setIsGenerating] = useState(false);
19 | const abortControllerRef = useRef(null);
20 | const responseEndRef = useRef(null);
21 | const [isWating, setIsWating] = useState(false);
22 |
23 | // 自动滚动到底部
24 | const scrollToBottom = () => {
25 | if (responseEndRef.current) {
26 | responseEndRef.current.scrollIntoView({ behavior: 'smooth' });
27 | }
28 | };
29 |
30 | const handleTest = async () => {
31 | if (!selectedModel || !prompt) return;
32 | setIsWating(true);
33 |
34 | setIsGenerating(true);
35 | // 保留之前的响应,添加分隔符
36 | setResponse('');
37 |
38 | try {
39 | abortControllerRef.current = new AbortController();
40 |
41 | const response = await fetch('/api/generate', {
42 | method: 'POST',
43 | headers: {
44 | 'Content-Type': 'application/json',
45 | },
46 | body: JSON.stringify({
47 | server,
48 | model: selectedModel,
49 | prompt,
50 | }),
51 | signal: abortControllerRef.current.signal,
52 | });
53 |
54 | if (!response.ok) {
55 | throw new Error('生成失败');
56 | }
57 |
58 | const reader = response.body?.getReader();
59 | if (!reader) return;
60 |
61 | while (true) {
62 | const { done, value } = await reader.read();
63 | if (done) break;
64 |
65 | const text = new TextDecoder().decode(value);
66 | setIsWating(false);
67 | setResponse(prev => prev + text);
68 | scrollToBottom();
69 | }
70 | } catch (error) {
71 | if (error instanceof Error && error.name === 'AbortError') {
72 | setResponse(prev => prev + '\n[已停止生成]');
73 | return;
74 | }
75 | console.error('生成出错:', error);
76 | setResponse(prev => prev + '\n' + t('modelTest.error'));
77 | } finally {
78 | setIsGenerating(false);
79 | abortControllerRef.current = null;
80 | scrollToBottom();
81 | }
82 | };
83 |
84 | const handleStopGeneration = () => {
85 | if (abortControllerRef.current) {
86 | abortControllerRef.current.abort();
87 | }
88 | };
89 |
90 | return (
91 |
96 |
97 |
98 |
101 |
111 |
112 |
113 |
114 |
117 |
126 |
127 |
128 |
131 |
132 |
135 |
136 | {isWating && !response? t('modelTest.responseEmpty') : response}
137 | {isGenerating && ▊}
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
152 | {isGenerating ? (
153 |
163 | ) : (
164 |
176 | )}
177 |
178 |
179 |
180 | );
181 | }
--------------------------------------------------------------------------------
/scripts/monitor.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from '@upstash/redis'
2 | import { promises as fs } from 'fs'
3 | import { join } from 'path'
4 | import { OllamaService } from '../src/types'
5 | import { fofaScan } from './fofa-scan.mjs'
6 | import {
7 | ModelInfo,
8 | fetchWithTimeout,
9 | checkService as checkServiceUtil,
10 | isFakeOllama,
11 | generateRequestBody,
12 | calculateTPS,
13 | isValidTPS
14 | } from '../src/lib/ollama-utils'
15 |
16 | const TEST_PROMPT = "Tell me a short joke"
17 | const CONCURRENT_LIMIT = 50 // 并发数限制
18 | const RESULT_FILE = join(process.cwd(), 'public', 'data.json')
19 | const COUNTRYS = process.env.COUNTRYS ? process.env.COUNTRYS.split(',') : ['US', 'CN', 'RU']
20 |
21 | // Redis 客户端配置
22 | const redis = process.env.UPSTASH_REDIS_URL && process.env.UPSTASH_REDIS_TOKEN
23 | ? new Redis({
24 | url: process.env.UPSTASH_REDIS_URL,
25 | token: process.env.UPSTASH_REDIS_TOKEN,
26 | })
27 | : null;
28 |
29 | // 检查服务是否可用 - 使用共享库
30 | const checkService = checkServiceUtil;
31 |
32 | // 测量TPS
33 | async function measureTPS(url: string, model: ModelInfo): Promise {
34 | try {
35 | const response = await fetchWithTimeout(`${url}/api/generate`, {
36 | method: 'POST',
37 | headers: {
38 | 'Content-Type': 'application/json',
39 | },
40 | body: JSON.stringify(generateRequestBody(model.name, TEST_PROMPT, false)),
41 | });
42 |
43 | if (!response.ok) {
44 | console.log(`性能测试返回非 200 状态码: ${url}, 状态码: ${response.status}`);
45 | return 0;
46 | }
47 |
48 | const data = await response.json();
49 |
50 | // 检查是否为 fake-ollama
51 | if (data.response && isFakeOllama(data.response)) {
52 | console.log(`检测到 fake-ollama: ${url}`);
53 | return { isFake: true };
54 | }
55 |
56 | // 使用 API 返回的 eval_count 和 eval_duration 计算 TPS
57 | if (data.eval_count && data.eval_duration) {
58 | const rawTps = (data.eval_count / data.eval_duration) * 1e9;
59 |
60 | // 检查 TPS 是否异常
61 | if (!isValidTPS(rawTps)) {
62 | console.warn(`检测到异常 TPS 值: ${rawTps.toFixed(2)} 来自服务器: ${url}`);
63 | return { isFake: true };
64 | }
65 |
66 | return calculateTPS(data);
67 | }
68 |
69 | // 如果 API 没有返回这些字段,则使用旧方法计算
70 | const endTime = Date.now();
71 | const startTime = new Date(data.created_at).getTime();
72 | const timeInSeconds = (endTime - startTime) / 1000;
73 | const tps = timeInSeconds > 0 ? 1 / timeInSeconds : 0;
74 |
75 | // 检查计算出的 TPS 是否合理
76 | if (!isValidTPS(tps)) {
77 | console.warn(`计算出的 TPS 异常: ${tps.toFixed(2)} 来自服务器: ${url}`);
78 | return { isFake: true };
79 | }
80 |
81 | return tps;
82 | } catch (error) {
83 | console.error(`性能测试出错 ${url}:`, error);
84 | return 0;
85 | }
86 | }
87 |
88 | // 保存单个结果到文件, 已废弃
89 | async function saveResult(service: OllamaService): Promise {
90 | try {
91 | let results: OllamaService[] = []
92 | try {
93 | const data = await fs.readFile(RESULT_FILE, 'utf-8')
94 | results = JSON.parse(data)
95 | } catch (error) {
96 | // 文件不存在或解析错误,使用空数组
97 | results = []
98 | console.error(`读取结果文件失败:`, error)
99 | }
100 |
101 | // 更新或添加结果
102 | const index = results.findIndex(r => r.server === service.server)
103 | if (index !== -1) {
104 | results[index] = service
105 | } else {
106 | results.push(service)
107 | }
108 |
109 | await fs.writeFile(RESULT_FILE, JSON.stringify(results, null, 2))
110 | console.log(`已保存服务结果: ${service.server}`)
111 | } catch (error) {
112 | console.error(`保存结果失败 ${service.server}:`, error)
113 | }
114 | }
115 |
116 | // 检查单个服务
117 | async function checkSingleService(url: string): Promise {
118 | console.log(`\n正在检查服务: ${url}`);
119 |
120 | try {
121 | const models = await checkService(url);
122 | const result: OllamaService = {
123 | server: url,
124 | models: [],
125 | tps: 0,
126 | lastUpdate: new Date().toISOString(),
127 | status: 'loading'
128 | };
129 |
130 | if (models && models.length > 0) {
131 | try {
132 | const tpsResult = await measureTPS(url, models[0]);
133 |
134 | // 检查是否为 fake-ollama
135 | if (typeof tpsResult === 'object' && 'isFake' in tpsResult) {
136 | result.status = 'fake';
137 | result.isFake = true;
138 | result.tps = 0;
139 | console.log(`服务 ${url} 是伪装的 Ollama 服务,已标记`);
140 | return null; // 返回 null 表示不保存这个服务
141 | } else {
142 | result.models = models.map(model => model.name);
143 | result.tps = tpsResult as number;
144 | result.status = 'success';
145 | }
146 | } catch (error) {
147 | console.error(`测量 TPS 失败 ${url}:`, error);
148 | result.status = 'error';
149 | }
150 | } else {
151 | result.status = 'error';
152 | }
153 |
154 | return result;
155 | } catch (error) {
156 | console.error(`检查服务失败 ${url}:`, error);
157 | return {
158 | server: url,
159 | models: [],
160 | tps: 0,
161 | lastUpdate: new Date().toISOString(),
162 | status: 'error'
163 | };
164 | }
165 | }
166 |
167 | // 并发执行检查任务
168 | async function runBatch(urls: string[]): Promise {
169 | const results: OllamaService[] = [];
170 | const promises = urls.map(async url => {
171 | try {
172 | const service = await checkSingleService(url);
173 | if (service && service.models && service.models.length > 0) {
174 | results.push(service);
175 | }
176 | } catch (error) {
177 | console.error(`处理服务失败 ${url}:`, error);
178 | }
179 | });
180 |
181 | await Promise.allSettled(promises);
182 | return results;
183 | }
184 |
185 | // 主函数
186 | export async function main() {
187 | if (!redis) {
188 | console.error('Redis 配置未设置,无法执行监控任务');
189 | return;
190 | }
191 |
192 | try {
193 | console.log('开始更新服务...');
194 |
195 | // 1. 从 Redis 的 Set 中读取服务器列表
196 | const encodedUrls = await redis.smembers('ollama:servers');
197 | const urls = encodedUrls.map(url => decodeURIComponent(url));
198 |
199 | console.log(`从 Redis 读取到 ${urls.length} 个服务器`);
200 |
201 | // 2. 从 Fofa 获取服务器列表
202 | const fofaUrls: string[] = [];
203 | const fofaPromises = COUNTRYS.map(country => fofaScan(country));
204 | const fofaResults = await Promise.all(fofaPromises);
205 |
206 | fofaResults.forEach(result => {
207 | fofaUrls.push(...result.hosts);
208 | });
209 |
210 | console.log(`从 Fofa 读取到 ${fofaUrls.length} 个服务器`);
211 |
212 | // 3. 合并服务器列表
213 | const allUrls = [...urls, ...fofaUrls];
214 |
215 | // 4. 清空结果文件
216 | await fs.writeFile(RESULT_FILE, '[]');
217 |
218 | // 有效服务器列表
219 | const validServers = new Set();
220 |
221 | // 3. 分批处理服务器
222 | for (let i = 0; i < allUrls.length; i += CONCURRENT_LIMIT) {
223 | const batch = allUrls.slice(i, i + CONCURRENT_LIMIT);
224 | console.log(`\n处理批次 ${Math.floor(i / CONCURRENT_LIMIT) + 1}/${Math.ceil(allUrls.length / CONCURRENT_LIMIT)} (${batch.length} 个服务)`);
225 |
226 | const results = await runBatch(batch);
227 |
228 | // 记录有效的服务器
229 | results.forEach(result => {
230 | if (result.models && result.models.length > 0 && result.status === 'success') {
231 | validServers.add(encodeURIComponent(result.server));
232 | }
233 | });
234 |
235 | // 保存当前批次的结果
236 | try {
237 | let existingResults: OllamaService[] = [];
238 | try {
239 | const data = await fs.readFile(RESULT_FILE, 'utf-8');
240 | existingResults = JSON.parse(data);
241 | } catch (error) {
242 | console.error('读取结果文件失败,使用空数组', error);
243 | }
244 |
245 | const newResults = [...existingResults, ...results];
246 | await fs.writeFile(RESULT_FILE, JSON.stringify(newResults, null, 2));
247 | } catch (error) {
248 | console.error('保存结果失败:', error);
249 | }
250 | }
251 |
252 | // 4. 更新 Redis 中的有效服务器列表
253 | if (validServers.size > 0) {
254 | console.log(`\n更新 Redis 中的有效服务器列表`);
255 | try {
256 | // 将 Set 转换为数组,并去重处理
257 | const serversArray = Array.from(validServers) as [string, ...string[]];
258 |
259 | // 先清空后批量添加(完全替换)
260 | await redis
261 | .pipeline()
262 | .del('ollama:servers')
263 | .sadd('ollama:servers', ...serversArray)
264 | .exec();
265 |
266 | console.log(`成功更新 ${serversArray.length} 个服务器`);
267 | } catch (err) {
268 | console.error('更新 Redis 失败:', err);
269 | // 这里可以添加重试逻辑
270 | }
271 | }
272 |
273 | console.log(`\n更新完成,共有 ${validServers.size} 个有效服务器`);
274 |
275 | } catch (error) {
276 | console.error('更新服务失败:', error);
277 | }
278 | }
279 |
280 | // 导出需要的函数
281 | export { checkService, measureTPS, saveResult }
282 |
283 | // 如果直接运行此文件则执行主函数
284 | if (require.main === module) {
285 | main()
286 | }
287 |
--------------------------------------------------------------------------------
/src/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { useTranslations } from 'next-intl';
5 | import { Header } from '@/components/Header';
6 | import { ModelFilter } from '@/components/ModelFilter';
7 | import { ServiceList } from '@/components/ServiceList';
8 | import { Footer } from '@/components/Footer';
9 | import { LanguageSwitch } from '@/components/LanguageSwitch';
10 | import { OllamaService, SortField, SortOrder } from '@/types';
11 | import { useParams } from 'next/navigation';
12 |
13 |
14 | export default function Home() {
15 | const t = useTranslations();
16 | const [services, setServices] = useState([]);
17 | const [countdown, setCountdown] = useState(0);
18 | const [detectingServices, setDetectingServices] = useState>(new Set());
19 | const [detectedResults, setDetectedResults] = useState([]);
20 |
21 | const { locale } = useParams();
22 |
23 | // 排序状态
24 | const [sortField, setSortField] = useState('tps');
25 | const [sortOrder, setSortOrder] = useState('desc');
26 |
27 | // 过滤状态
28 | const [selectedModels, setSelectedModels] = useState([]);
29 | const [availableModels, setAvailableModels] = useState([]);
30 | const [searchTerm, setSearchTerm] = useState('');
31 |
32 | // 分页状态
33 | const [currentPage, setCurrentPage] = useState(1);
34 | const [pageSize, setPageSize] = useState(20);
35 |
36 | // 客户端渲染标记
37 | const [isClient, setIsClient] = useState(false);
38 |
39 | const fetchData = async () => {
40 | try {
41 | // 使用绝对路径
42 | const response = await fetch('/data.json', {
43 | // 添加 cache 控制
44 | cache: 'no-store', // 或者使用 'force-cache' 如果你想要缓存
45 | });
46 | const data = await response.json();
47 | // 确保所有服务都有 loading 属性
48 | const servicesWithLoading = data.map((service: OllamaService) => ({
49 | ...service,
50 | loading: false,
51 | status: service.models.length > 0 ? 'success' : 'error'
52 | }));
53 | setServices(servicesWithLoading);
54 |
55 | // 更新可用模型列表
56 | const models = new Set();
57 | servicesWithLoading.forEach((service: OllamaService) => {
58 | service.models.forEach(model => models.add(model));
59 | });
60 | setAvailableModels(Array.from(models).sort());
61 | } catch (error) {
62 | console.error('Error fetching data:', error);
63 | }
64 | };
65 |
66 | const handleDetect = async (urls: string[]): Promise => {
67 | try {
68 | setDetectingServices(new Set(urls));
69 |
70 | for (const url of urls) {
71 | try {
72 | const response = await fetch('/api/detect', {
73 | method: 'POST',
74 | headers: {
75 | 'Content-Type': 'application/json',
76 | },
77 | body: JSON.stringify({ url }),
78 | });
79 |
80 | if (!response.ok) {
81 | console.error(`检测失败: ${url}, 状态码: ${response.status}`);
82 | setDetectedResults(prev => [...prev, {
83 | server: url,
84 | models: [],
85 | tps: 0,
86 | lastUpdate: new Date().toISOString(),
87 | status: 'error'
88 | }]);
89 | continue;
90 | }
91 |
92 | const result = await response.json();
93 | setDetectedResults(prev => [...prev, result]);
94 |
95 | } catch (error) {
96 | console.error(`检测出错: ${url}`, error);
97 | setDetectedResults(prev => [...prev, {
98 | server: url,
99 | models: [],
100 | tps: 0,
101 | lastUpdate: new Date().toISOString(),
102 | status: 'error'
103 | }]);
104 | } finally {
105 | setDetectingServices(prev => {
106 | const next = new Set(prev);
107 | next.delete(url);
108 | return next;
109 | });
110 | }
111 | }
112 |
113 | setCountdown(5);
114 | } catch (error) {
115 | console.error('检测过程出错:', error);
116 | setDetectingServices(new Set());
117 | }
118 | };
119 |
120 | useEffect(() => {
121 | // 只在客户端执行
122 | if (typeof window !== 'undefined') {
123 | fetchData();
124 | }
125 | }, []);
126 |
127 | useEffect(() => {
128 | if (countdown > 0) {
129 | const timer = setInterval(() => {
130 | setCountdown(prev => prev - 1);
131 | }, 1000);
132 | return () => clearInterval(timer);
133 | }
134 | }, [countdown]);
135 |
136 | useEffect(() => {
137 | setIsClient(true);
138 | }, []);
139 |
140 | // 排序函数
141 | const toggleSort = (field: SortField) => {
142 | if (sortField === field) {
143 | setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
144 | } else {
145 | setSortField(field);
146 | setSortOrder('desc');
147 | }
148 | };
149 |
150 | // 切换模型选择
151 | const toggleModelSelection = (model: string) => {
152 | setSelectedModels(prev =>
153 | prev.includes(model)
154 | ? prev.filter(m => m !== model)
155 | : [...prev, model]
156 | );
157 | };
158 |
159 | // 移除单个选中的模型
160 | const removeSelectedModel = (model: string) => {
161 | setSelectedModels(prev => prev.filter(m => m !== model));
162 | };
163 |
164 | // 清空所有选中的模型
165 | const clearSelectedModels = () => {
166 | setSelectedModels([]);
167 | };
168 |
169 | // 过滤和排序服务列表
170 | const filteredAndSortedServices = services
171 | .filter(service =>
172 | // 过滤掉伪装服务
173 | (!service.isFake) &&
174 | (selectedModels.length === 0 ||
175 | service.models.some(model => selectedModels.includes(model)))
176 | )
177 | .sort((a, b) => {
178 | const multiplier = sortOrder === 'asc' ? 1 : -1;
179 |
180 | // 只有在有选中的模型时,才特殊处理排序
181 | if (selectedModels.length > 0) {
182 | // 检查服务是否包含选中的模型
183 | const aHasSelectedModel = a.models.some(model => selectedModels.includes(model));
184 | const bHasSelectedModel = b.models.some(model => selectedModels.includes(model));
185 |
186 | // 如果 a 包含选中的模型但 b 不包含,则 a 排在前面
187 | if (aHasSelectedModel && !bHasSelectedModel) return -1;
188 | // 如果 b 包含选中的模型但 a 不包含,则 b 排在前面
189 | if (!aHasSelectedModel && bHasSelectedModel) return 1;
190 | }
191 |
192 | // 将 loading 状态的服务排在最前面
193 | if (a.loading && !b.loading) return -1;
194 | if (!a.loading && b.loading) return 1;
195 |
196 | if (sortField === 'tps') {
197 | return (a.tps - b.tps) * multiplier;
198 | } else {
199 | return (new Date(a.lastUpdate).getTime() - new Date(b.lastUpdate).getTime()) * multiplier;
200 | }
201 | });
202 |
203 | // 计算分页数据
204 | const paginatedServices = filteredAndSortedServices.slice(
205 | (currentPage - 1) * pageSize,
206 | currentPage * pageSize
207 | );
208 |
209 | // 计算总页数
210 | const totalPages = Math.ceil(filteredAndSortedServices.length / pageSize);
211 |
212 | // 页面改变处理
213 | const handlePageChange = (page: number) => {
214 | setCurrentPage(page);
215 | window.scrollTo({ top: 0, behavior: 'smooth' });
216 | };
217 |
218 | // 每页显示数量改变处理
219 | const handlePageSizeChange = (newSize: number) => {
220 | setPageSize(newSize);
221 | setCurrentPage(1);
222 | };
223 |
224 | // 当过滤条件改变时,重置页码
225 | useEffect(() => {
226 | setCurrentPage(1);
227 | }, [selectedModels, sortField, sortOrder]);
228 |
229 | return (
230 |
231 |
232 |
254 |
255 |
256 |
268 |
269 |
278 |
279 |
280 |
281 | {t('service.total', { count: filteredAndSortedServices.length })}
282 | {selectedModels.length > 0 && t('service.filtered', { models: selectedModels.join(', ') })}
283 |
284 |
285 |
286 |
287 |
288 | );
289 | }
--------------------------------------------------------------------------------
/src/components/ServiceList.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl';
2 | import { formatDistanceToNow } from 'date-fns';
3 | import { zhCN, enUS } from 'date-fns/locale';
4 | import { PAGE_SIZE_OPTIONS } from '@/constants';
5 | import { useParams } from 'next/navigation';
6 | import { OllamaService } from '@/types';
7 | import { BeakerIcon } from '@heroicons/react/24/outline';
8 | import { useState } from 'react';
9 | import { ModelTestModal } from './ModelTestModal';
10 |
11 | interface ServiceListProps {
12 | services: OllamaService[];
13 | currentPage: number;
14 | pageSize: number;
15 | totalPages: number;
16 | isClient: boolean;
17 | onPageChange: (page: number) => void;
18 | onPageSizeChange: (size: number) => void;
19 | }
20 |
21 | export function ServiceList({
22 | services,
23 | currentPage,
24 | pageSize,
25 | totalPages,
26 | isClient,
27 | onPageChange,
28 | onPageSizeChange,
29 | }: ServiceListProps) {
30 | const t = useTranslations();
31 | const params = useParams();
32 | const locale = params.locale as string;
33 |
34 | const [selectedService, setSelectedService] = useState(null);
35 | const [isTestModalOpen, setIsTestModalOpen] = useState(false);
36 |
37 | // const formatDate = (date: string) => {
38 | // if (!isClient) {
39 | // return new Date(date).toISOString();
40 | // }
41 | // return formatDistanceToNow(new Date(date), {
42 | // addSuffix: true,
43 | // locale: locale === 'zh' ? zhCN : enUS,
44 | // });
45 | // };
46 |
47 | // 生成页码数组
48 | const getPageNumbers = () => {
49 | const pages = [];
50 | const maxVisiblePages = 5;
51 |
52 | if (totalPages <= maxVisiblePages) {
53 | for (let i = 1; i <= totalPages; i++) {
54 | pages.push(i);
55 | }
56 | } else {
57 | if (currentPage <= 3) {
58 | for (let i = 1; i <= 4; i++) pages.push(i);
59 | pages.push('...');
60 | pages.push(totalPages);
61 | } else if (currentPage >= totalPages - 2) {
62 | pages.push(1);
63 | pages.push('...');
64 | for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i);
65 | } else {
66 | pages.push(1);
67 | pages.push('...');
68 | for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i);
69 | pages.push('...');
70 | pages.push(totalPages);
71 | }
72 | }
73 | return pages;
74 | };
75 |
76 | const handleTest = (service: OllamaService) => {
77 | setSelectedService(service);
78 | setIsTestModalOpen(true);
79 | };
80 |
81 | return (
82 | <>
83 |
84 |
85 |
86 |
87 |
88 | |
89 | {t('service.server')}
90 | |
91 |
92 | {t('service.models')}
93 | |
94 |
95 | TPS
96 | |
97 |
98 | {t('service.lastUpdate', { value: '' })}
99 | |
100 |
101 | {t('service.actions')}
102 | |
103 |
104 |
105 |
106 | {services.map((service, index) => (
107 |
109 | |
110 |
116 | {service.server}
117 |
118 | {service.isFake && (
119 |
121 | {t('detect.fake')}
122 |
123 | )}
124 | |
125 |
126 | {service.loading ? (
127 |
128 | ) : (
129 |
130 | {service.models.map((model, idx) => (
131 |
136 | {model}
137 |
138 | ))}
139 |
140 | )}
141 | |
142 |
143 | {service.loading ? (
144 |
145 | ) : (
146 | t('service.tps', { value: service.tps.toFixed(2) })
147 | )}
148 | |
149 |
150 | {service.loading ? (
151 |
152 | ) : (
153 | isClient && (
154 |
162 | )
163 | )}
164 | |
165 |
166 |
178 | |
179 |
180 | ))}
181 |
182 |
183 |
184 | {/* 分页控制 */}
185 |
186 |
187 | {t('pagination.perPage')}
188 |
198 |
199 |
200 | {t('pagination.showing', {
201 | from: (currentPage - 1) * pageSize + 1,
202 | to: Math.min(currentPage * pageSize, services.length),
203 | total: services.length
204 | })}
205 |
206 |
207 |
208 | {/* 分页导航 */}
209 | {totalPages > 1 && (
210 |
211 |
212 |
223 |
234 | {getPageNumbers().map((page, index) => (
235 |
249 | ))}
250 |
261 |
272 |
273 |
274 | )}
275 |
276 |
277 | {selectedService && (
278 | {
281 | setIsTestModalOpen(false);
282 | setSelectedService(null);
283 | }}
284 | server={selectedService.server}
285 | models={selectedService.models}
286 | />
287 | )}
288 | >
289 | );
290 | }
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl';
2 | import { useState, useEffect, useRef } from 'react';
3 | import { MagnifyingGlassIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
4 | import { Modal } from './Modal';
5 | import { OllamaService } from '@/types';
6 |
7 | interface HeaderProps {
8 | countdown: number;
9 | detectingServices: Set;
10 | detectedResults: OllamaService[];
11 | onDetect: (urls: string[]) => Promise;
12 | }
13 |
14 | export function Header({ countdown, detectingServices, detectedResults, onDetect }: HeaderProps) {
15 | const t = useTranslations();
16 | const [isModalOpen, setIsModalOpen] = useState(false);
17 | const [urlInput, setUrlInput] = useState('');
18 | const [detectResults, setDetectResults] = useState([]);
19 | const [isDetecting, setIsDetecting] = useState(false);
20 |
21 | // 记录已经更新过的服务器
22 | const updatedServersRef = useRef(new Set());
23 |
24 | const handleDetect = async () => {
25 | const urls = urlInput.split('\n').filter(url => url.trim());
26 | if (urls.length === 0) return;
27 |
28 | setIsDetecting(true);
29 | try {
30 | // 过滤出新的服务地址
31 | const existingUrls = new Set(detectResults.map(result => result.server));
32 | const newUrls = urls.filter(url => !existingUrls.has(url));
33 |
34 | // 更新现有服务的状态为 loading
35 | setDetectResults(prev => prev.map(result =>
36 | urls.includes(result.server)
37 | ? { ...result, loading: true, status: 'loading' as const }
38 | : result
39 | ));
40 |
41 | // 添加新的服务
42 | if (newUrls.length > 0) {
43 | const initialServices = newUrls.map(url => ({
44 | server: url,
45 | models: [],
46 | tps: 0,
47 | lastUpdate: new Date().toISOString(),
48 | loading: true,
49 | status: 'loading' as const
50 | }));
51 | setDetectResults(prev => [...prev, ...initialServices]);
52 | }
53 |
54 | // 开始检测
55 | await onDetect(urls);
56 | } finally {
57 | setIsDetecting(false);
58 | }
59 | };
60 |
61 | // 更新检测结果的状态
62 | useEffect(() => {
63 | setDetectResults(prev =>
64 | prev.map(result => {
65 | const isDetecting = detectingServices.has(result.server);
66 | // 查找最新的检测结果
67 | const latestResult = detectedResults.find(r => r.server === result.server);
68 |
69 | if (latestResult && !isDetecting) {
70 | // 如果检测成功且有可用模型,且状态发生了变化,且未更新过,异步更新服务器列表
71 | if (latestResult.status === 'success' &&
72 | latestResult.models.length > 0 &&
73 | result.status !== 'success' &&
74 | !updatedServersRef.current.has(latestResult.server) &&
75 | !latestResult.isFake) {
76 | // 标记该服务器已更新
77 | updatedServersRef.current.add(latestResult.server);
78 |
79 | fetch('/api/update-servers', {
80 | method: 'POST',
81 | headers: {
82 | 'Content-Type': 'application/json',
83 | },
84 | body: JSON.stringify({ server: latestResult.server }),
85 | })
86 | .then(res => res.json())
87 | .then(data => {
88 | if (data.success) {
89 | console.log(data.exists
90 | ? `服务器已在监控列表中: ${latestResult.server}`
91 | : `服务器已添加到监控列表: ${latestResult.server}`
92 | );
93 | }
94 | })
95 | .catch(error => {
96 | // 如果更新失败,移除标记以便下次重试
97 | updatedServersRef.current.delete(latestResult.server);
98 | console.error('更新服务器列表失败:', error);
99 | });
100 | }
101 |
102 | // 如果有最新结果且不在检测中,使用最新结果
103 | return {
104 | ...latestResult,
105 | loading: false,
106 | status: latestResult.models.length > 0 ? 'success' as const : 'error' as const
107 | };
108 | }
109 |
110 | return {
111 | ...result,
112 | loading: isDetecting,
113 | status: isDetecting ? 'loading' as const : result.models.length > 0 ? 'success' as const : 'error' as const
114 | };
115 | })
116 | );
117 | }, [detectingServices, detectedResults]);
118 |
119 | // 重置检测时清除已更新服务器的记录
120 | const handleNewDetection = () => {
121 | setDetectResults([]);
122 | setUrlInput('');
123 | updatedServersRef.current.clear();
124 | };
125 |
126 | const handleDownload = () => {
127 | const blob = new Blob([JSON.stringify(detectResults, null, 2)], { type: 'application/json' });
128 | const url = window.URL.createObjectURL(blob);
129 | const a = document.createElement('a');
130 | a.href = url;
131 | a.download = `ollama-detection-${new Date().toISOString()}.json`;
132 | document.body.appendChild(a);
133 | a.click();
134 | window.URL.revokeObjectURL(url);
135 | document.body.removeChild(a);
136 | };
137 |
138 | return (
139 |
140 |
141 | {t('title')}
142 |
143 |
161 |
162 |
setIsModalOpen(false)}
165 | title={t('detect.title')}
166 | >
167 |
168 | {detectResults.length === 0 ? (
169 | <>
170 |
171 | {t('detect.description')}
172 |
173 |
294 |
295 |
296 | );
297 | }
--------------------------------------------------------------------------------