From 7b00c480915fc49e5b4a3e125ca6f9838ff6e073 Mon Sep 17 00:00:00 2001 From: 2233admin <57929895+2233admin@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:38:33 +0800 Subject: [PATCH 01/41] feat: add source workflow canvas --- frontend/.dockerignore | 8 +- frontend/Dockerfile | 48 +- frontend/components.json | 42 +- frontend/index.html | 26 +- frontend/nginx.conf | 58 +- frontend/package-lock.json | 244 ++ frontend/package.json | 5 + frontend/postcss.config.js | 12 +- frontend/src/App.tsx | 12 + frontend/src/api/client.ts | 28 +- frontend/src/api/endpoints.ts | 480 +-- frontend/src/api/types.ts | 3 + frontend/src/components/AgentFlightBoard.tsx | 590 ++++ frontend/src/components/Card.tsx | 2 +- frontend/src/components/ChannelConfigForm.tsx | 1822 +++++------ frontend/src/components/CommandPalette.tsx | 199 ++ frontend/src/components/ConfirmDialog.tsx | 116 +- frontend/src/components/DataTable.tsx | 120 +- frontend/src/components/EmptyState.tsx | 76 +- frontend/src/components/ErrorAlert.tsx | 8 +- frontend/src/components/ErrorBoundary.tsx | 86 +- frontend/src/components/Layout.tsx | 68 +- frontend/src/components/LoadingSpinner.tsx | 2 +- .../src/components/NotifierConfigForm.tsx | 8 + frontend/src/components/PageHeader.tsx | 11 +- frontend/src/components/Pagination.tsx | 182 +- frontend/src/components/SkeletonLoader.tsx | 126 +- frontend/src/components/StatusBadge.tsx | 32 +- frontend/src/components/TruncatedText.tsx | 140 +- .../src/components/opencli/MetricTile.tsx | 72 + .../src/components/opencli/PanelHeader.tsx | 28 + .../components/opencli/PlaybackControls.tsx | 47 + frontend/src/components/opencli/index.ts | 3 + frontend/src/components/ui/alert-dialog.tsx | 262 +- frontend/src/components/ui/badge.tsx | 54 +- frontend/src/components/ui/button.tsx | 91 +- frontend/src/components/ui/dialog.tsx | 216 +- frontend/src/components/ui/input.tsx | 32 +- frontend/src/components/ui/select.tsx | 272 +- frontend/src/components/ui/separator.tsx | 58 +- frontend/src/components/ui/skeleton.tsx | 24 +- frontend/src/components/ui/tooltip.tsx | 52 +- frontend/src/i18n/en.ts | 30 + frontend/src/i18n/index.ts | 36 +- frontend/src/i18n/zh.ts | 30 + frontend/src/index.css | 212 +- .../src/lib/collectionWorkflowModel.test.ts | 153 + frontend/src/lib/collectionWorkflowModel.ts | 395 +++ frontend/src/lib/notificationDisplay.test.ts | 35 + frontend/src/lib/notificationDisplay.ts | 43 + frontend/src/lib/topologyModel.test.ts | 188 ++ frontend/src/lib/topologyModel.ts | 419 +++ frontend/src/lib/utils.ts | 12 +- frontend/src/main.tsx | 50 +- frontend/src/pages/AgentsPage.tsx | 1528 ++++----- frontend/src/pages/BrowsersPage.tsx | 1942 +++++------ frontend/src/pages/DashboardPage.tsx | 307 +- frontend/src/pages/NodesPage.tsx | 2130 ++++++------- frontend/src/pages/NotificationsPage.tsx | 62 +- frontend/src/pages/ProvidersPage.tsx | 760 ++--- frontend/src/pages/RecordsPage.tsx | 708 +++-- frontend/src/pages/SchedulesPage.tsx | 1338 ++++---- frontend/src/pages/SourcesPage.tsx | 2828 +++++++++++++---- frontend/src/pages/TasksPage.tsx | 662 ++-- frontend/src/pages/TopologyPage.tsx | 631 ++++ frontend/src/pages/WorkersPage.tsx | 234 +- frontend/tailwind.config.js | 21 +- frontend/tsconfig.json | 46 +- frontend/vite.config.ts | 70 +- 69 files changed, 13046 insertions(+), 7589 deletions(-) create mode 100644 frontend/src/components/AgentFlightBoard.tsx create mode 100644 frontend/src/components/CommandPalette.tsx create mode 100644 frontend/src/components/opencli/MetricTile.tsx create mode 100644 frontend/src/components/opencli/PanelHeader.tsx create mode 100644 frontend/src/components/opencli/PlaybackControls.tsx create mode 100644 frontend/src/components/opencli/index.ts create mode 100644 frontend/src/lib/collectionWorkflowModel.test.ts create mode 100644 frontend/src/lib/collectionWorkflowModel.ts create mode 100644 frontend/src/lib/notificationDisplay.test.ts create mode 100644 frontend/src/lib/notificationDisplay.ts create mode 100644 frontend/src/lib/topologyModel.test.ts create mode 100644 frontend/src/lib/topologyModel.ts create mode 100644 frontend/src/pages/TopologyPage.tsx diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 4ae174f4..f4e6b34e 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,4 +1,4 @@ -node_modules/ -dist/ -.env* -*.md +node_modules/ +dist/ +.env* +*.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 163c1095..7ba79086 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,24 +1,24 @@ -# ── Stage 1: build React app ────────────────────────────────────────────────── -ARG REGISTRY= -FROM ${REGISTRY}node:22-alpine AS builder - -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm ci --legacy-peer-deps - -COPY . . -RUN npm run build - -# ── Stage 2: serve with nginx ───────────────────────────────────────────────── -ARG REGISTRY= -FROM ${REGISTRY}nginx:1.27-alpine AS runtime - -# Copy built assets -COPY --from=builder /app/dist /usr/share/nginx/html - -# Nginx config: SPA routing + proxy /api to backend -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +# ── Stage 1: build React app ────────────────────────────────────────────────── +ARG REGISTRY= +FROM ${REGISTRY}node:22-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --legacy-peer-deps + +COPY . . +RUN npm run build + +# ── Stage 2: serve with nginx ───────────────────────────────────────────────── +ARG REGISTRY= +FROM ${REGISTRY}nginx:1.27-alpine AS runtime + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Nginx config: SPA routing + proxy /api to backend +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/components.json b/frontend/components.json index 70e29164..de2b6655 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -1,21 +1,21 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/index.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/index.html b/frontend/index.html index f7d71528..6623389c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,13 @@ - - - - - - - OpenCLI Admin - - -
- - - + + + + + + + OpenCLI Admin + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 83de47b2..2eca7fc3 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,29 +1,29 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Gzip - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Proxy API calls to backend (HTTP + WebSocket) - location /api/ { - proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 300s; - - # WebSocket support (agent reverse channel) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # SPA fallback: all unknown paths → index.html - location / { - try_files $uri $uri/ /index.html; - } -} +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Proxy API calls to backend (HTTP + WebSocket) + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + + # WebSocket support (agent reverse channel) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # SPA fallback: all unknown paths → index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd990954..3e1150f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,11 +15,15 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.11.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "elkjs": "^0.11.1", "i18next": "^23.16.0", "lucide-react": "^0.468.0", "react": "^18.3.1", @@ -2078,6 +2082,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2135,6 +2172,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -2165,6 +2211,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -2186,6 +2238,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2252,6 +2323,48 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2526,6 +2639,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2535,6 +2654,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2604,6 +2739,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -2659,6 +2816,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -2704,6 +2870,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2807,6 +3008,12 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmmirror.com/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4439,6 +4646,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4558,6 +4774,34 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index f662cbac..7677d15c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test src/lib/*.test.ts", "build": "tsc -b && vite build", "preview": "vite preview" }, @@ -16,11 +17,15 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.11.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "elkjs": "^0.11.1", "i18next": "^23.16.0", "lucide-react": "^0.468.0", "react": "^18.3.1", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b7..5eec88dd 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55e72968..73948094 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ +import { lazy, Suspense } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import Layout from './components/Layout' +import { PageLoader } from './components/LoadingSpinner' import DashboardPage from './pages/DashboardPage' import SourcesPage from './pages/SourcesPage' import TasksPage from './pages/TasksPage' @@ -11,6 +13,8 @@ import AgentsPage from './pages/AgentsPage' import ProvidersPage from './pages/ProvidersPage' import NodesPage from './pages/NodesPage' +const TopologyPage = lazy(() => import('./pages/TopologyPage')) + export default function App() { return ( @@ -18,6 +22,14 @@ export default function App() { }> } /> } /> + }> + + + } + /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 91f935b6..3d8177fd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,14 +1,14 @@ -import axios from 'axios' - -export const apiClient = axios.create({ - baseURL: '/api/v1', - headers: { 'Content-Type': 'application/json' }, -}) - -apiClient.interceptors.response.use( - (res) => res, - (err) => { - const message = err.response?.data?.error || err.message || 'Unknown error' - return Promise.reject(new Error(message)) - } -) +import axios from 'axios' + +export const apiClient = axios.create({ + baseURL: '/api/v1', + headers: { 'Content-Type': 'application/json' }, +}) + +apiClient.interceptors.response.use( + (res) => res, + (err) => { + const message = err.response?.data?.error || err.message || 'Unknown error' + return Promise.reject(new Error(message)) + } +) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 16329ed7..baf282ae 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -1,240 +1,240 @@ -import { apiClient } from './client' -import type { - AIAgent, - ApiResponse, - ModelProvider, - BrowserBinding, - ChromeEndpoint, - CollectedRecord, - CollectionTask, - CronSchedule, - DataSource, - DashboardActivity, - DashboardStats, - EdgeNode, - EdgeNodeEvent, - NodeStats, - NotificationLog, - NotificationRule, - SystemConfig, - TaskRun, - TaskRunEvent, - WorkerNode, -} from './types' - -// ── Dashboard ────────────────────────────────────────────────────────────────── -export const getDashboardStats = (params?: { range?: string; start?: string; end?: string }) => - apiClient.get>('/dashboard/stats', { params }).then((r) => r.data.data) - -export const getDashboardActivity = (params?: { days?: number; tz_offset?: number }) => - apiClient.get>('/dashboard/activity', { params }).then((r) => r.data.data) - -// ── Sources ──────────────────────────────────────────────────────────────────── -export const listSources = (params?: { page?: number; limit?: number; enabled?: boolean }) => - apiClient.get>('/sources', { params }).then((r) => r.data) - -export const getSource = (id: string) => - apiClient.get>(`/sources/${id}`).then((r) => r.data.data) - -export const createSource = (data: Partial) => - apiClient.post>('/sources', data).then((r) => r.data.data) - -export const updateSource = (id: string, data: Partial) => - apiClient.patch>(`/sources/${id}`, data).then((r) => r.data.data) - -export const deleteSource = (id: string) => - apiClient.delete>(`/sources/${id}`).then((r) => r.data) - -export const testSourceConnectivity = (id: string) => - apiClient - .post>(`/sources/${id}/test`) - .then((r) => r.data.data) - -// ── Tasks ────────────────────────────────────────────────────────────────────── -export const listTasks = (params?: { - source_id?: string - status?: string - page?: number - limit?: number -}) => apiClient.get>('/tasks', { params }).then((r) => r.data) - -export const triggerTask = ( - source_id: string, - parameters?: Record, - agent_id?: string, -) => - apiClient - .post>('/tasks/trigger', { - source_id, - parameters: parameters ?? {}, - ...(agent_id ? { agent_id } : {}), - }) - .then((r) => r.data.data) - -export const getTask = (id: string) => - apiClient.get>(`/tasks/${id}`).then((r) => r.data.data) - -export const listTaskRuns = (task_id: string) => - apiClient.get>(`/tasks/${task_id}/runs`).then((r) => r.data) - -export const listRunEvents = (task_id: string, run_id: string) => - apiClient.get>(`/tasks/${task_id}/runs/${run_id}/events`).then((r) => r.data.data) - -// ── Records ──────────────────────────────────────────────────────────────────── -export const listRecords = (params?: { - source_id?: string - task_id?: string - status?: string - search?: string - page?: number - limit?: number -}) => apiClient.get>('/records', { params }).then((r) => r.data) - -export const getRecord = (id: string) => - apiClient.get>(`/records/${id}`).then((r) => r.data.data) - -export const deleteRecord = (id: string) => - apiClient.delete>(`/records/${id}`).then((r) => r.data) - -export const batchDeleteRecords = (ids: string[]) => - apiClient.post>('/records/batch-delete', { ids }).then((r) => r.data) - -export const clearAllRecords = (source_id?: string) => - apiClient.delete>('/records', { params: source_id ? { source_id } : {} }).then((r) => r.data) - -// ── Schedules ────────────────────────────────────────────────────────────────── -export const listSchedules = (params?: { source_id?: string; enabled?: boolean }) => - apiClient.get>('/schedules', { params }).then((r) => r.data) - -export const createSchedule = (data: Partial) => - apiClient.post>('/schedules', data).then((r) => r.data.data) - -export const updateSchedule = (id: string, data: Partial) => - apiClient.patch>(`/schedules/${id}`, data).then((r) => r.data.data) - -export const deleteSchedule = (id: string) => - apiClient.delete>(`/schedules/${id}`).then((r) => r.data) - -// ── Notifications ────────────────────────────────────────────────────────────── -export const listNotificationRules = () => - apiClient.get>('/notifications/rules').then((r) => r.data) - -export const createNotificationRule = (data: Partial) => - apiClient - .post>('/notifications/rules', data) - .then((r) => r.data.data) - -export const updateNotificationRule = (id: string, data: Partial) => - apiClient - .patch>(`/notifications/rules/${id}`, data) - .then((r) => r.data.data) - -export const deleteNotificationRule = (id: string) => - apiClient.delete>(`/notifications/rules/${id}`).then((r) => r.data) - -export const listNotificationLogs = (params?: { rule_id?: string }) => - apiClient - .get>('/notifications/logs', { params }) - .then((r) => r.data) - -// ── Model Providers ──────────────────────────────────────────────────────────── -export const listProviders = () => - apiClient.get>('/providers').then((r) => r.data) - -export const createProvider = (data: Partial) => - apiClient.post>('/providers', data).then((r) => r.data.data) - -export const updateProvider = (id: string, data: Partial) => - apiClient.patch>(`/providers/${id}`, data).then((r) => r.data.data) - -export const deleteProvider = (id: string) => - apiClient.delete>(`/providers/${id}`).then((r) => r.data) - -// ── Agents ───────────────────────────────────────────────────────────────────── -export const listAgents = (params?: { enabled?: boolean }) => - apiClient.get>('/agents', { params }).then((r) => r.data) - -export const createAgent = (data: Partial) => - apiClient.post>('/agents', data).then((r) => r.data.data) - -export const updateAgent = (id: string, data: Partial) => - apiClient.patch>(`/agents/${id}`, data).then((r) => r.data.data) - -export const deleteAgent = (id: string) => - apiClient.delete>(`/agents/${id}`).then((r) => r.data) - -// ── Browser bindings ─────────────────────────────────────────────────────────── -export const listBrowserBindings = () => - apiClient.get>('/browsers/bindings').then((r) => r.data) - -export const createBrowserBinding = (data: { browser_endpoint: string; site: string; notes?: string }) => - apiClient.post>('/browsers/bindings', data).then((r) => r.data.data) - -export const deleteBrowserBinding = (id: string) => - apiClient.delete>(`/browsers/bindings/${id}`).then((r) => r.data) - -export const addChromeInstance = (count = 1, mode: 'bridge' | 'cdp' = 'bridge', agent_url = '', agent_protocol: 'http' | 'ws' | '' = '') => { - const params = new URLSearchParams({ count: String(count), mode }) - if (agent_url) params.set('agent_url', agent_url) - if (agent_protocol) params.set('agent_protocol', agent_protocol) - return apiClient.post>(`/browsers/chrome-instances?${params}`).then((r) => r.data.data) -} - -export const updateChromeInstanceConfig = (endpoint: string, data: { mode?: string; agent_url?: string | null; agent_protocol?: string | null }) => { - const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - return apiClient.patch>(`/browsers/instances/${b64}`, data).then((r) => r.data.data) -} - -export const removeChromeInstance = (n: number) => - apiClient.delete>(`/browsers/chrome-instances/${n}`).then((r) => r.data) - -export const restartApi = () => - apiClient.post>('/browsers/restart-api').then((r) => r.data) - -// ── System ───────────────────────────────────────────────────────────────────── -export const getHealth = () => - apiClient.get<{ status: string; version: string; task_executor: string }>('/health').then((r) => r.data) - -export const getSystemConfig = () => - apiClient.get>('/system/config').then((r) => r.data.data) - -export const updateSystemConfig = (data: Partial) => - apiClient.patch>('/system/config', data).then((r) => r.data.data) - -export const getWsAgentStatus = () => - apiClient.get>('/browsers/agents/ws-status').then((r) => r.data.data) - -// ── Workers ──────────────────────────────────────────────────────────────────── -export const listWorkers = () => - apiClient.get>('/workers').then((r) => r.data) - -export const getCeleryStats = () => - apiClient.get>>('/workers/celery-stats').then((r) => r.data.data) - -// ── Edge Nodes ───────────────────────────────────────────────────────────────── -export const listNodes = () => - apiClient.get>('/nodes').then((r) => r.data) - -export const getNodeEvents = (id: string) => - apiClient.get>(`/nodes/${id}/events`).then((r) => r.data) - -export const getNodeStats = (id: string, params?: { range?: string; start?: string; end?: string }) => - apiClient.get>(`/nodes/${id}/stats`, { params }).then((r) => r.data.data) - -export const deleteNode = (id: string) => - apiClient.delete>(`/nodes/${id}`).then((r) => r.data) - -export const getInstallScriptUrl = (base: string) => - `${base}/api/v1/nodes/install/agent.sh` - -export const getChromePool = () => - apiClient - .get>('/workers/chrome-pool') - .then((r) => r.data.data) - -export const updateChromeEndpointMode = (endpoint: string, mode: 'bridge' | 'cdp') => { - const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - return apiClient - .patch>(`/workers/chrome-pool/${b64}/mode`, { mode }) - .then((r) => r.data.data) -} +import { apiClient } from './client' +import type { + AIAgent, + ApiResponse, + ModelProvider, + BrowserBinding, + ChromeEndpoint, + CollectedRecord, + CollectionTask, + CronSchedule, + DataSource, + DashboardActivity, + DashboardStats, + EdgeNode, + EdgeNodeEvent, + NodeStats, + NotificationLog, + NotificationRule, + SystemConfig, + TaskRun, + TaskRunEvent, + WorkerNode, +} from './types' + +// ── Dashboard ────────────────────────────────────────────────────────────────── +export const getDashboardStats = (params?: { range?: string; start?: string; end?: string }) => + apiClient.get>('/dashboard/stats', { params }).then((r) => r.data.data) + +export const getDashboardActivity = (params?: { days?: number; tz_offset?: number }) => + apiClient.get>('/dashboard/activity', { params }).then((r) => r.data.data) + +// ── Sources ──────────────────────────────────────────────────────────────────── +export const listSources = (params?: { page?: number; limit?: number; enabled?: boolean }) => + apiClient.get>('/sources', { params }).then((r) => r.data) + +export const getSource = (id: string) => + apiClient.get>(`/sources/${id}`).then((r) => r.data.data) + +export const createSource = (data: Partial) => + apiClient.post>('/sources', data).then((r) => r.data.data) + +export const updateSource = (id: string, data: Partial) => + apiClient.patch>(`/sources/${id}`, data).then((r) => r.data.data) + +export const deleteSource = (id: string) => + apiClient.delete>(`/sources/${id}`).then((r) => r.data) + +export const testSourceConnectivity = (id: string) => + apiClient + .post>(`/sources/${id}/test`) + .then((r) => r.data.data) + +// ── Tasks ────────────────────────────────────────────────────────────────────── +export const listTasks = (params?: { + source_id?: string + status?: string + page?: number + limit?: number +}) => apiClient.get>('/tasks', { params }).then((r) => r.data) + +export const triggerTask = ( + source_id: string, + parameters?: Record, + agent_id?: string, +) => + apiClient + .post>('/tasks/trigger', { + source_id, + parameters: parameters ?? {}, + ...(agent_id ? { agent_id } : {}), + }) + .then((r) => r.data.data) + +export const getTask = (id: string) => + apiClient.get>(`/tasks/${id}`).then((r) => r.data.data) + +export const listTaskRuns = (task_id: string) => + apiClient.get>(`/tasks/${task_id}/runs`).then((r) => r.data) + +export const listRunEvents = (task_id: string, run_id: string) => + apiClient.get>(`/tasks/${task_id}/runs/${run_id}/events`).then((r) => r.data.data) + +// ── Records ──────────────────────────────────────────────────────────────────── +export const listRecords = (params?: { + source_id?: string + task_id?: string + status?: string + search?: string + page?: number + limit?: number +}) => apiClient.get>('/records', { params }).then((r) => r.data) + +export const getRecord = (id: string) => + apiClient.get>(`/records/${id}`).then((r) => r.data.data) + +export const deleteRecord = (id: string) => + apiClient.delete>(`/records/${id}`).then((r) => r.data) + +export const batchDeleteRecords = (ids: string[]) => + apiClient.post>('/records/batch-delete', { ids }).then((r) => r.data) + +export const clearAllRecords = (source_id?: string) => + apiClient.delete>('/records', { params: source_id ? { source_id } : {} }).then((r) => r.data) + +// ── Schedules ────────────────────────────────────────────────────────────────── +export const listSchedules = (params?: { source_id?: string; enabled?: boolean }) => + apiClient.get>('/schedules', { params }).then((r) => r.data) + +export const createSchedule = (data: Partial) => + apiClient.post>('/schedules', data).then((r) => r.data.data) + +export const updateSchedule = (id: string, data: Partial) => + apiClient.patch>(`/schedules/${id}`, data).then((r) => r.data.data) + +export const deleteSchedule = (id: string) => + apiClient.delete>(`/schedules/${id}`).then((r) => r.data) + +// ── Notifications ────────────────────────────────────────────────────────────── +export const listNotificationRules = () => + apiClient.get>('/notifications/rules').then((r) => r.data) + +export const createNotificationRule = (data: Partial) => + apiClient + .post>('/notifications/rules', data) + .then((r) => r.data.data) + +export const updateNotificationRule = (id: string, data: Partial) => + apiClient + .patch>(`/notifications/rules/${id}`, data) + .then((r) => r.data.data) + +export const deleteNotificationRule = (id: string) => + apiClient.delete>(`/notifications/rules/${id}`).then((r) => r.data) + +export const listNotificationLogs = (params?: { rule_id?: string }) => + apiClient + .get>('/notifications/logs', { params }) + .then((r) => r.data) + +// ── Model Providers ──────────────────────────────────────────────────────────── +export const listProviders = () => + apiClient.get>('/providers').then((r) => r.data) + +export const createProvider = (data: Partial) => + apiClient.post>('/providers', data).then((r) => r.data.data) + +export const updateProvider = (id: string, data: Partial) => + apiClient.patch>(`/providers/${id}`, data).then((r) => r.data.data) + +export const deleteProvider = (id: string) => + apiClient.delete>(`/providers/${id}`).then((r) => r.data) + +// ── Agents ───────────────────────────────────────────────────────────────────── +export const listAgents = (params?: { enabled?: boolean }) => + apiClient.get>('/agents', { params }).then((r) => r.data) + +export const createAgent = (data: Partial) => + apiClient.post>('/agents', data).then((r) => r.data.data) + +export const updateAgent = (id: string, data: Partial) => + apiClient.patch>(`/agents/${id}`, data).then((r) => r.data.data) + +export const deleteAgent = (id: string) => + apiClient.delete>(`/agents/${id}`).then((r) => r.data) + +// ── Browser bindings ─────────────────────────────────────────────────────────── +export const listBrowserBindings = () => + apiClient.get>('/browsers/bindings').then((r) => r.data) + +export const createBrowserBinding = (data: { browser_endpoint: string; site: string; notes?: string }) => + apiClient.post>('/browsers/bindings', data).then((r) => r.data.data) + +export const deleteBrowserBinding = (id: string) => + apiClient.delete>(`/browsers/bindings/${id}`).then((r) => r.data) + +export const addChromeInstance = (count = 1, mode: 'bridge' | 'cdp' = 'bridge', agent_url = '', agent_protocol: 'http' | 'ws' | '' = '') => { + const params = new URLSearchParams({ count: String(count), mode }) + if (agent_url) params.set('agent_url', agent_url) + if (agent_protocol) params.set('agent_protocol', agent_protocol) + return apiClient.post>(`/browsers/chrome-instances?${params}`).then((r) => r.data.data) +} + +export const updateChromeInstanceConfig = (endpoint: string, data: { mode?: string; agent_url?: string | null; agent_protocol?: string | null }) => { + const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + return apiClient.patch>(`/browsers/instances/${b64}`, data).then((r) => r.data.data) +} + +export const removeChromeInstance = (n: number) => + apiClient.delete>(`/browsers/chrome-instances/${n}`).then((r) => r.data) + +export const restartApi = () => + apiClient.post>('/browsers/restart-api').then((r) => r.data) + +// ── System ───────────────────────────────────────────────────────────────────── +export const getHealth = () => + apiClient.get<{ status: string; version: string; task_executor: string }>('/health').then((r) => r.data) + +export const getSystemConfig = () => + apiClient.get>('/system/config').then((r) => r.data.data) + +export const updateSystemConfig = (data: Partial) => + apiClient.patch>('/system/config', data).then((r) => r.data.data) + +export const getWsAgentStatus = () => + apiClient.get>('/browsers/agents/ws-status').then((r) => r.data.data) + +// ── Workers ──────────────────────────────────────────────────────────────────── +export const listWorkers = () => + apiClient.get>('/workers').then((r) => r.data) + +export const getCeleryStats = () => + apiClient.get>>('/workers/celery-stats').then((r) => r.data.data) + +// ── Edge Nodes ───────────────────────────────────────────────────────────────── +export const listNodes = () => + apiClient.get>('/nodes').then((r) => r.data) + +export const getNodeEvents = (id: string) => + apiClient.get>(`/nodes/${id}/events`).then((r) => r.data) + +export const getNodeStats = (id: string, params?: { range?: string; start?: string; end?: string }) => + apiClient.get>(`/nodes/${id}/stats`, { params }).then((r) => r.data.data) + +export const deleteNode = (id: string) => + apiClient.delete>(`/nodes/${id}`).then((r) => r.data) + +export const getInstallScriptUrl = (base: string) => + `${base}/api/v1/nodes/install/agent.sh` + +export const getChromePool = () => + apiClient + .get>('/workers/chrome-pool') + .then((r) => r.data.data) + +export const updateChromeEndpointMode = (endpoint: string, mode: 'bridge' | 'cdp') => { + const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + return apiClient + .patch>(`/workers/chrome-pool/${b64}/mode`, { mode }) + .then((r) => r.data.data) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 937e7963..29a0e8f6 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -141,6 +141,9 @@ export interface NotificationLog { status: string response_data?: Record error_message?: string + ack_status: string + ack_data?: Record + acked_at?: string created_at: string } diff --git a/frontend/src/components/AgentFlightBoard.tsx b/frontend/src/components/AgentFlightBoard.tsx new file mode 100644 index 00000000..7c7ba2fb --- /dev/null +++ b/frontend/src/components/AgentFlightBoard.tsx @@ -0,0 +1,590 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { formatInTimeZone } from 'date-fns-tz' +import { + AlertTriangle, + Bell, + Bot, + CheckCircle, + CircleDollarSign, + CircleDot, + Cpu, + Database, + MessageSquare, + Timer, + Wrench, + type LucideIcon, +} from 'lucide-react' +import { listRunEvents } from '../api/endpoints' +import type { DashboardStats, TaskRunEvent } from '../api/types' +import Card from './Card' +import StatusBadge from './StatusBadge' +import { MetricTile, PanelHeader, PlaybackControls } from './opencli' + +type RecentRun = DashboardStats['recent_runs'][number] +type FlightKind = 'user' | 'agent' | 'model' | 'tool' | 'store' | 'notify' | 'output' +type FlightStatus = 'done' | 'running' | 'failed' | 'queued' + +interface FlightStep { + id: string + role: string + title: string + message: string + kind: FlightKind + status: FlightStatus + elapsedMs?: number + tokens?: number + costUsd?: number + detail?: Record +} + +const KIND_META: Record = { + user: { + icon: MessageSquare, + accent: 'text-zinc-100', + chip: 'border-zinc-300/35 bg-zinc-300/10 text-zinc-100', + rail: 'from-zinc-300/60 to-white/10', + }, + agent: { + icon: Bot, + accent: 'text-emerald-200', + chip: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200', + rail: 'from-emerald-400/60 to-white/10', + }, + model: { + icon: Cpu, + accent: 'text-primary-100', + chip: 'border-primary-500/45 bg-primary-500/12 text-primary-100', + rail: 'from-primary-500/70 to-white/10', + }, + tool: { + icon: Wrench, + accent: 'text-amber-200', + chip: 'border-amber-400/40 bg-amber-400/10 text-amber-200', + rail: 'from-amber-400/65 to-white/10', + }, + store: { + icon: Database, + accent: 'text-sky-200', + chip: 'border-sky-400/35 bg-sky-400/10 text-sky-200', + rail: 'from-sky-400/60 to-white/10', + }, + notify: { + icon: Bell, + accent: 'text-violet-200', + chip: 'border-violet-400/35 bg-violet-400/10 text-violet-200', + rail: 'from-violet-400/60 to-white/10', + }, + output: { + icon: CheckCircle, + accent: 'text-emerald-200', + chip: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200', + rail: 'from-emerald-400/60 to-white/10', + }, +} + +const STATUS_RING: Record = { + done: 'border-white/14 bg-white/[0.045]', + running: 'border-zinc-100/45 bg-zinc-100/[0.075]', + failed: 'border-primary-500/60 bg-primary-500/[0.12]', + queued: 'border-white/10 bg-black/20 opacity-60', +} + +const TRIGGER_LABELS: Record = { + manual: '手动', + scheduled: '定时', + webhook: 'Webhook', +} + +function normalizeRunStatus(status: string) { + if (status === 'success') return 'completed' + return status +} + +function isRunDone(status: string) { + return ['completed', 'success'].includes(status) +} + +function isRunActive(status: string) { + return ['running', 'pending', 'ai_processing'].includes(status) +} + +function formatDuration(ms?: number) { + if (ms == null) return 'N/A' + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +function formatCost(value?: number) { + if (value == null || Number.isNaN(value)) return 'N/A' + if (value === 0) return '$0' + if (value < 0.01) return `$${value.toFixed(5)}` + return `$${value.toFixed(3)}` +} + +function formatTokens(value?: number) { + if (value == null || Number.isNaN(value)) return 'N/A' + return new Intl.NumberFormat('en-US').format(value) +} + +function metricFromDetail(detail: Record | undefined, keys: string[]) { + if (!detail) return undefined + const queue: unknown[] = [detail] + const wanted = keys.map((key) => key.toLowerCase()) + + while (queue.length > 0) { + const current = queue.shift() + if (!current || typeof current !== 'object') continue + for (const [rawKey, value] of Object.entries(current as Record)) { + const key = rawKey.toLowerCase() + if (wanted.some((item) => key === item || key.endsWith(`_${item}`))) { + const numeric = typeof value === 'number' ? value : Number(value) + if (Number.isFinite(numeric)) return numeric + } + if (value && typeof value === 'object') queue.push(value) + } + } + + return undefined +} + +function stepKind(step: string, message: string): { kind: FlightKind; role: string; title: string } { + const text = `${step} ${message}`.toLowerCase() + if (text.includes('model') || text.includes('ai') || text.includes('llm') || text.includes('processor')) { + return { kind: 'model', role: 'Model Call', title: step || 'AI 处理' } + } + if (text.includes('tool') || text.includes('collect') || text.includes('fetch') || text.includes('scrape')) { + return { kind: 'tool', role: 'Tool', title: step || '工具执行' } + } + if (text.includes('store') || text.includes('record') || text.includes('save') || text.includes('normalize')) { + return { kind: 'store', role: 'Data', title: step || '数据入库' } + } + if (text.includes('notify') || text.includes('webhook') || text.includes('message')) { + return { kind: 'notify', role: 'Notify', title: step || '通知分发' } + } + if (text.includes('finish') || text.includes('complete') || text.includes('done')) { + return { kind: 'output', role: 'Output', title: step || '结果' } + } + return { kind: 'agent', role: 'Agent', title: step || '运行阶段' } +} + +function statusFromEvent(event: TaskRunEvent, index: number, total: number, runStatus: string): FlightStatus { + if (event.level === 'error') return 'failed' + if (isRunActive(runStatus) && index === total - 1) return 'running' + return 'done' +} + +function stepsFromEvents(events: TaskRunEvent[], run: RecentRun): FlightStep[] { + const sorted = [...events].sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)) + + return sorted.map((event, index) => { + const meta = stepKind(event.step, event.message) + const tokens = metricFromDetail(event.detail, [ + 'total_tokens', + 'tokens', + 'input_tokens', + 'output_tokens', + 'reasoning_tokens', + ]) + const costUsd = metricFromDetail(event.detail, ['cost_usd', 'total_cost_usd', 'usd', 'cost']) + + return { + id: event.id, + role: meta.role, + title: meta.title, + message: event.message, + kind: meta.kind, + status: statusFromEvent(event, index, sorted.length, run.status), + elapsedMs: event.elapsed_ms, + tokens, + costUsd, + detail: event.detail, + } + }) +} + +function fallbackSteps(run: RecentRun): FlightStep[] { + const finalStatus: FlightStatus = run.status === 'failed' + ? 'failed' + : isRunActive(run.status) + ? 'running' + : 'done' + + return [ + { + id: `${run.id}-source`, + role: 'User', + title: '触发任务', + message: TRIGGER_LABELS[run.task_trigger_type] ?? run.task_trigger_type, + kind: 'user', + status: 'done', + }, + { + id: `${run.id}-collect`, + role: 'Tool', + title: '采集源', + message: run.source_name, + kind: 'tool', + status: finalStatus === 'failed' ? 'done' : finalStatus, + }, + { + id: `${run.id}-agent`, + role: 'Agent', + title: '处理数据', + message: `${run.records_collected} 条记录`, + kind: 'agent', + status: finalStatus === 'failed' ? 'done' : finalStatus, + elapsedMs: run.duration_ms, + }, + { + id: `${run.id}-output`, + role: 'Output', + title: run.status === 'failed' ? '运行失败' : '生成结果', + message: run.status === 'failed' ? '等待事件详情' : '记录已进入控制台', + kind: 'output', + status: finalStatus, + elapsedMs: run.duration_ms, + }, + ] +} + +function safeJson(detail?: Record) { + if (!detail || Object.keys(detail).length === 0) return 'N/A' + return JSON.stringify(detail, null, 2) +} + +function RunSelector({ + runs, + selectedId, + onSelect, +}: { + runs: RecentRun[] + selectedId: string | null + onSelect: (id: string) => void +}) { + return ( +
+ {runs.slice(0, 8).map((run) => ( + + ))} +
+ ) +} + +function FlightNode({ + step, + active, + nodeRef, + onSelect, +}: { + step: FlightStep + active: boolean + nodeRef?: (node: HTMLButtonElement | null) => void + onSelect: () => void +}) { + const meta = KIND_META[step.kind] + const Icon = meta.icon + const StatusIcon = step.status === 'failed' ? AlertTriangle : step.status === 'done' ? CheckCircle : CircleDot + + return ( + + ) +} + +function FlightConnector({ active }: { active: boolean }) { + return ( +
+ +
+ ) +} + +export default function AgentFlightBoard({ runs }: { runs: RecentRun[] }) { + const stepRefs = useRef>(new Map()) + const preferredRunId = useMemo(() => { + return runs.find((run) => run.status === 'failed')?.id + ?? runs.find((run) => isRunActive(run.status))?.id + ?? runs[0]?.id + ?? null + }, [runs]) + const [selectedRunId, setSelectedRunId] = useState(preferredRunId) + const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] + + useEffect(() => { + if (!runs.length) return + if (!selectedRunId || !runs.some((run) => run.id === selectedRunId)) { + setSelectedRunId(preferredRunId) + } + }, [preferredRunId, runs, selectedRunId]) + + const { data: events, isFetching } = useQuery({ + queryKey: ['dashboard-run-events', selectedRun?.task_id, selectedRun?.id], + queryFn: () => listRunEvents(selectedRun.task_id, selectedRun.id), + enabled: Boolean(selectedRun), + refetchInterval: isRunActive(selectedRun?.status ?? '') ? 5_000 : false, + }) + + const steps = useMemo(() => { + if (!selectedRun) return [] + if (events?.length) return stepsFromEvents(events, selectedRun) + return fallbackSteps(selectedRun) + }, [events, selectedRun]) + const failedStep = steps.find((step) => step.status === 'failed') + const [activeStepId, setActiveStepId] = useState(null) + const [isPlaying, setIsPlaying] = useState(false) + + useEffect(() => { + const next = failedStep?.id ?? steps[0]?.id ?? null + setActiveStepId((current) => current && steps.some((step) => step.id === current) ? current : next) + }, [failedStep?.id, steps]) + + const activeStep = steps.find((step) => step.id === activeStepId) ?? failedStep ?? steps[0] + const activeStepIndex = activeStep ? steps.findIndex((step) => step.id === activeStep.id) : -1 + const tokenTotal = steps.reduce((sum, step) => sum + (step.tokens ?? 0), 0) + const costTotal = steps.reduce((sum, step) => sum + (step.costUsd ?? 0), 0) + const tokenKnown = steps.some((step) => step.tokens != null) + const costKnown = steps.some((step) => step.costUsd != null) + + useEffect(() => { + setIsPlaying(false) + }, [selectedRun?.id]) + + useEffect(() => { + if (!activeStepId) return + const node = stepRefs.current.get(activeStepId) + if (!node) return + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + node.scrollIntoView({ + behavior: reduceMotion ? 'auto' : 'smooth', + block: 'nearest', + inline: 'center', + }) + }, [activeStepId]) + + useEffect(() => { + if (!isPlaying || steps.length <= 1) return + const timer = window.setInterval(() => { + setActiveStepId((current) => { + const index = Math.max(0, steps.findIndex((step) => step.id === current)) + if (index >= steps.length - 1) { + setIsPlaying(false) + return current + } + return steps[index + 1].id + }) + }, 1800) + + return () => window.clearInterval(timer) + }, [isPlaying, steps]) + + const selectStepAt = (index: number) => { + if (!steps.length) return + const bounded = Math.min(Math.max(index, 0), steps.length - 1) + setActiveStepId(steps[bounded].id) + } + + const handleReset = () => { + setIsPlaying(false) + selectStepAt(0) + } + + const handlePrevious = () => { + setIsPlaying(false) + selectStepAt((activeStepIndex >= 0 ? activeStepIndex : 0) - 1) + } + + const handleNext = () => { + setIsPlaying(false) + selectStepAt((activeStepIndex >= 0 ? activeStepIndex : 0) + 1) + } + + if (!runs.length) { + return ( + + 运行故事板} /> +
暂无运行记录
+
+ ) + } + + return ( + + +

运行故事板

+ {selectedRun && } + + {selectedRun ? formatInTimeZone(new Date(selectedRun.created_at), 'Asia/Shanghai', 'MM-dd HH:mm:ss') : ''} + + + )} + actions={} + /> + +
+ + +
+
+
+

FLOW STRIP

+

运行链路

+
+ 0 ? `${Math.max(activeStepIndex + 1, 1)} / ${steps.length}` : '0 / 0'} + onToggle={() => setIsPlaying((value) => !value)} + onPrevious={handlePrevious} + onNext={handleNext} + onReset={handleReset} + /> +
+ +
+
+ {steps.map((step, index) => ( +
+ { + if (node) stepRefs.current.set(step.id, node) + else stepRefs.current.delete(step.id) + }} + onSelect={() => { + setIsPlaying(false) + setActiveStepId(step.id) + }} + /> + {index < steps.length - 1 && ( + + )} +
+ ))} +
+
+
+ + +
+
+ ) +} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index c0438d56..d4d5ac5d 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -10,7 +10,7 @@ export default function Card({ children, className, padding = true }: Props) { return (
- - {children} - {h &&

{h}

} -
- ) +function formFieldName(seed: string | undefined, fallback: string) { + const slug = (seed ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return `channel-config-${slug || fallback}` } +function Field({ + label: l, + hint: h, + required, + children, +}: { + label: string + hint?: string + required?: boolean + children: React.ReactNode +}) { + return ( +
+ + {children} + {h &&

{h}

} +
+ ) +} + function TextInput({ value, onChange, placeholder, required, + ariaLabel, }: { value: string onChange: (v: string) => void placeholder?: string required?: boolean + ariaLabel?: string }) { return ( onChange(e.target.value)} - placeholder={placeholder} - required={required} - /> - ) -} - + placeholder={placeholder} + required={required} + /> + ) +} + function NumberInput({ value, onChange, placeholder, min, + ariaLabel, }: { value: number | '' onChange: (v: number | '') => void placeholder?: string min?: number + ariaLabel?: string }) { return ( onChange(e.target.value === '' ? '' : Number(e.target.value))} - placeholder={placeholder} - /> - ) -} - + min={min} + onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} + placeholder={placeholder} + /> + ) +} + function SelectInput({ value, onChange, options, + ariaLabel, }: { value: string onChange: (v: string) => void options: { value: string; label: string }[] + ariaLabel?: string }) { return ( - - ) -} - -// Key-value pair list (for selectors / headers / params / args / defaults) -type KVPair = { key: string; value: string } - -function KVList({ - pairs, - onChange, - keyPlaceholder, - valuePlaceholder, -}: { - pairs: KVPair[] - onChange: (pairs: KVPair[]) => void - keyPlaceholder?: string - valuePlaceholder?: string -}) { - const update = (i: number, field: 'key' | 'value', v: string) => - onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) - - const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) - - return ( -
- {pairs.map((p, i) => ( -
+ + ) +} + +// Key-value pair list (for selectors / headers / params / args / defaults) +type KVPair = { key: string; value: string } + +function KVList({ + pairs, + onChange, + keyPlaceholder, + valuePlaceholder, +}: { + pairs: KVPair[] + onChange: (pairs: KVPair[]) => void + keyPlaceholder?: string + valuePlaceholder?: string +}) { + const update = (i: number, field: 'key' | 'value', v: string) => + onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) + + const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) + + return ( +
+ {pairs.map((p, i) => ( +
update(i, 'key', e.target.value)} - placeholder={keyPlaceholder ?? 'key'} - /> + placeholder={keyPlaceholder ?? 'key'} + /> update(i, 'value', e.target.value)} - placeholder={valuePlaceholder ?? 'value'} - /> + placeholder={valuePlaceholder ?? 'value'} + /> -
- ))} - -
- ) -} - -function kvToObj(pairs: KVPair[]): Record { - return Object.fromEntries(pairs.filter((p) => p.key).map((p) => [p.key, p.value])) -} - -function objToKv(obj: Record | undefined): KVPair[] { - if (!obj) return [] - return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })) -} - -// ── Per-channel config forms ────────────────────────────────────────────────── - -function RSSConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - return ( -
- - onChange({ ...config, feed_url: v })} - placeholder="https://hnrss.org/frontpage" - required - /> - -
- - onChange({ ...config, max_entries: v === '' ? undefined : v })} - placeholder="50" - min={1} - /> - - - onChange({ ...config, timeout: v === '' ? undefined : v })} - placeholder="30" - min={1} - /> - -
-
- ) -} - -function APIConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const auth = (config.auth as Record) ?? {} - const authType = auth.type ?? 'none' - const [params, setParams] = useState(objToKv(config.params as Record)) - const [headers, setHeaders] = useState(objToKv(config.headers as Record)) - - const update = (patch: Partial>) => onChange({ ...config, ...patch }) - - const updateParams = (pairs: KVPair[]) => { - setParams(pairs) - update({ params: kvToObj(pairs) }) - } - - const updateHeaders = (pairs: KVPair[]) => { - setHeaders(pairs) - update({ headers: kvToObj(pairs) }) - } - - const updateAuth = (patch: Partial>) => - update({ auth: { ...auth, ...patch } }) - - return ( -
- - update({ base_url: v })} - placeholder="https://api.github.com" - required - /> - -
- - update({ endpoint: v })} - placeholder="/repos/owner/repo/issues" - required - /> - - - update({ method: v })} - options={['GET', 'POST', 'PUT', 'PATCH'].map((m) => ({ value: m, label: m }))} - /> - - - update({ result_path: v })} - placeholder="data.items" - /> - -
- - - update({ auth: { type: v } })} - options={[ - { value: 'none', label: t('channelConfig.authNone') }, - { value: 'bearer', label: t('channelConfig.authBearer') }, - { value: 'basic', label: t('channelConfig.authBasic') }, - { value: 'api_key', label: t('channelConfig.authApiKey') }, - ]} - /> - - - {authType === 'bearer' && ( - - updateAuth({ token_env: v })} - placeholder="GITHUB_TOKEN" - /> - - )} - {authType === 'basic' && ( -
- - updateAuth({ username: v })} - placeholder="{{secret:API_USER}}" - /> - - - updateAuth({ password: v })} - placeholder="{{secret:API_PASS}}" - /> - -
- )} - {authType === 'api_key' && ( -
- - updateAuth({ header: v })} - placeholder="X-API-Key" - /> - - - updateAuth({ key_env: v })} - placeholder="MY_API_KEY" - /> - -
- )} - - - - - - - - - update({ timeout: v === '' ? undefined : v })} - placeholder="30" - min={1} - /> - -
- ) -} - -function WebScraperConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const [selectors, setSelectors] = useState( - objToKv(config.selectors as Record), - ) - - const update = (patch: Partial>) => onChange({ ...config, ...patch }) - - const updateSelectors = (pairs: KVPair[]) => { - setSelectors(pairs) - update({ selectors: kvToObj(pairs) }) - } - - return ( -
- - update({ url: v })} - placeholder="https://news.ycombinator.com" - required - /> - - - update({ list_selector: v })} - placeholder=".athing" - /> - - - - - - update({ timeout: v === '' ? undefined : v })} - placeholder="30" - min={1} - /> - -
- ) -} - -function CLIConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const cmdArr = (config.command as string[]) ?? [] - const [cmdStr, setCmdStr] = useState(cmdArr.join(' ')) - const [defaults, setDefaults] = useState( - objToKv(config.defaults as Record), - ) - const [envVars, setEnvVars] = useState( - objToKv(config.env as Record), - ) - - const update = (patch: Partial>) => onChange({ ...config, ...patch }) - - const updateCmd = (v: string) => { - setCmdStr(v) - // Split respecting quoted strings - const parts = v.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] - update({ command: parts }) - } - - const updateDefaults = (pairs: KVPair[]) => { - setDefaults(pairs) - update({ defaults: kvToObj(pairs) }) - } - - const updateEnv = (pairs: KVPair[]) => { - setEnvVars(pairs) - update({ env: kvToObj(pairs) }) - } - - return ( -
- - update({ binary: v })} - placeholder="curl" - required - /> - - - - - - update({ output_format: v })} - options={[ - { value: 'json', label: t('channelConfig.outputJson') }, - { value: 'text', label: t('channelConfig.outputText') }, - ]} - /> - - - - - - - - - update({ timeout: v === '' ? undefined : v })} - placeholder="60" - min={1} - /> - -
- ) -} - -// ── OpenCLI presets ────────────────────────────────────────────────────────── - -type Preset = { - label: string - group: string - site: string - command: string - args: Record - /** Placeholder/description shown for each arg value input */ - argHints?: Record -} - -const OPENCLI_PRESETS: Preset[] = [ - // ── 国内 (Chinese, login required) ─────────────────────────────────────── - // Fields: rank, title, author, likes, url - { group: '🇨🇳 国内', label: '小红书 · 搜索', site: 'xiaohongshu', command: 'search', - args: { keyword: '', limit: '20' }, - argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, - // Fields: id, title, type, likes, url - { group: '🇨🇳 国内', label: '小红书 · 用户笔记', site: 'xiaohongshu', command: 'user', - args: { id: '', limit: '20' }, - argHints: { id: '用户 ID(从主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, - // Fields: rank, title, author, play, danmaku - { group: '🇨🇳 国内', label: 'Bilibili · 热门视频', site: 'bilibili', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, author, score, url - { group: '🇨🇳 国内', label: 'Bilibili · 排行榜', site: 'bilibili', command: 'ranking', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: id, author, text, likes, url - { group: '🇨🇳 国内', label: 'Bilibili · 关注动态', site: 'bilibili', command: 'dynamic', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, author, plays, url - { group: '🇨🇳 国内', label: 'Bilibili · 收藏夹', site: 'bilibili', command: 'favorite', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, plays, likes, date, url - { group: '🇨🇳 国内', label: 'Bilibili · 用户视频', site: 'bilibili', command: 'user-videos', - args: { uid: '', limit: '20' }, - argHints: { uid: 'UP 主 UID(从个人主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, - // Fields: rank, title, heat, answers, url - { group: '🇨🇳 国内', label: '知乎 · 热榜', site: 'zhihu', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, author, votes, content - { group: '🇨🇳 国内', label: '知乎 · 问题回答', site: 'zhihu', command: 'question', - args: { id: '', limit: '10' }, - argHints: { id: '问题 ID(从 URL 中获取,如 /question/123456789)', limit: '返回答案数(默认 10)' } }, - // Fields: rank, word(→title), hot_value, category, label, url - { group: '🇨🇳 国内', label: '微博 · 热搜', site: 'weibo', command: 'hot', - args: {}, - argHints: {} }, - // Fields: rank, title, score, author, url - { group: '🇨🇳 国内', label: 'V2EX · 热门话题', site: 'v2ex', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, score, author, url - { group: '🇨🇳 国内', label: 'V2EX · 最新话题', site: 'v2ex', command: 'latest', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, author, text(→content), likes, url - { group: '🇨🇳 国内', label: '雪球 · 动态', site: 'xueqiu', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, symbol, name(→title), price, changePercent, heat - { group: '🇨🇳 国内', label: '雪球 · 热门股票', site: 'xueqiu', command: 'hot-stock', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20,最大 50)' } }, - // Fields: name(→title), symbol, price, changePercent, marketCap - { group: '🇨🇳 国内', label: '雪球 · 股票行情', site: 'xueqiu', command: 'stock', - args: { symbol: '601318' }, - argHints: { symbol: 'A 股代码(如 601318 中国平安)或港股(如 00700 腾讯)' } }, - // Fields: rank, title, price, mall, comments, url - { group: '🇨🇳 国内', label: '什么值得买 · 搜索', site: 'smzdm', command: 'search', - args: { keyword: '', limit: '20' }, - argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, - // Fields: name(→title), salary, company, area, experience, degree, skills, boss, url - { group: '🇨🇳 国内', label: 'Boss直聘 · 职位搜索', site: 'boss', command: 'search', - args: { keyword: '', city: '101010100', limit: '20' }, - argHints: { keyword: '职位名称或关键词(必填,如 "前端工程师")', city: '城市代码(101010100=北京,101020100=上海,101280100=广州,101280600=深圳)', limit: '返回条数(默认 20)' } }, - // Fields: rank, name(→title), type, score, price, url - { group: '🇨🇳 国内', label: '携程 · 目的地搜索', site: 'ctrip', command: 'search', - args: { query: '', limit: '15' }, - argHints: { query: '目的地或景点名称(必填,如 "三亚")', limit: '返回条数(默认 15)' } }, - // Fields: title, author, description(→content), subscribers, episodes, updated - { group: '🇨🇳 国内', label: '小宇宙 · 播客信息', site: 'xiaoyuzhou', command: 'podcast', - args: { id: '' }, - argHints: { id: '播客 ID(从 URL 获取,如 5e280fbd418a84a0463d3e3b)' } }, - // Fields: eid, title, duration, plays, date - { group: '🇨🇳 国内', label: '小宇宙 · 单集列表', site: 'xiaoyuzhou', command: 'podcast-episodes', - args: { id: '', limit: '15' }, - argHints: { id: '播客 ID(同上)', limit: '返回集数(最多 15,受 SSR 限制)' } }, - - // ── Public (no login required) ──────────────────────────────────────────── - // Fields: rank, title, score, author, comments, url - { group: '🌐 Public', label: 'Hacker News · top stories', site: 'hackernews', command: 'top', - args: { limit: '20' }, - argHints: { limit: '返回条数(1–500)' } }, - // Fields: rank, title, description, url - { group: '🌐 Public', label: 'BBC · latest news', site: 'bbc', command: 'news', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, date, section, url - { group: '🌐 Public', label: 'Reuters · search', site: 'reuters', command: 'search', - args: { query: 'technology', limit: '20' }, - argHints: { query: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, - - // ── Global (login required) ─────────────────────────────────────────────── - // Fields: rank, topic(→title), tweets - { group: '🌍 Global', label: 'Twitter/X · trending', site: 'twitter', command: 'trending', - args: {}, - argHints: {} }, - // Fields: id, author, text(→content), likes, retweets, replies, views, created_at, url - { group: '🌍 Global', label: 'Twitter/X · timeline', site: 'twitter', command: 'timeline', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: id, author, text(→content), likes, views, url - { group: '🌍 Global', label: 'Twitter/X · search', site: 'twitter', command: 'search', - args: { query: '', limit: '20' }, - argHints: { query: '搜索关键词,支持运算符(必填,如 "AI lang:en")', limit: '返回条数(默认 20)' } }, - // Fields: title, subreddit, score, comments, url - { group: '🌍 Global', label: 'Twitter/X · bookmarks', site: 'twitter', command: 'bookmarks', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: title, subreddit, author, upvotes, comments, url - { group: '🌍 Global', label: 'Reddit · frontpage', site: 'reddit', command: 'frontpage', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, subreddit, score, comments, url - { group: '🌍 Global', label: 'Reddit · hot', site: 'reddit', command: 'hot', - args: { limit: '20' }, - argHints: { subreddit: '子版块名称(可选,留空则为全站热门,如 "programming")', limit: '返回条数(默认 20)' } }, - // Fields: title, subreddit, score, comments, url - { group: '🌍 Global', label: 'Reddit · saved posts', site: 'reddit', command: 'saved', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, channel(→author), views, duration, url - { group: '🌍 Global', label: 'YouTube · search', site: 'youtube', command: 'search', - args: { query: 'technology', limit: '10' }, - argHints: { query: '搜索关键词(必填)', limit: '返回条数(最多 10)' } }, - // Fields: rank, title, company, location, listed(→published_at), salary, url - { group: '🌍 Global', label: 'LinkedIn · job search', site: 'linkedin', command: 'search', - args: { query: 'AI engineer', limit: '20' }, - argHints: { query: '职位名称或关键词(必填)', limit: '返回条数(默认 20)' } }, - // Fields: symbol, name(→title), price, change, changePercent, open, high, low, volume, marketCap - { group: '🌍 Global', label: 'Yahoo Finance · quote', site: 'yahoo-finance', command: 'quote', - args: { symbol: 'AAPL' }, - argHints: { symbol: '股票代码(如 AAPL、GOOGL、TSLA、SPY)' } }, - // Fields: symbol, name(→title), price, change, changePct, peRatio, eps, marketCap - { group: '🌍 Global', label: 'Barchart · stock quote', site: 'barchart', command: 'quote', - args: { symbol: 'AAPL' }, - argHints: { symbol: '股票代码(如 AAPL、SPY、QQQ)' } }, -] - -const PRESET_DEFAULT = OPENCLI_PRESETS[0] - -// ── Derived lookup structures ───────────────────────────────────────────────── - -const SITE_LABELS: Record = { - xiaohongshu: '小红书', bilibili: 'Bilibili', zhihu: '知乎', - weibo: '微博', v2ex: 'V2EX', xueqiu: '雪球', - smzdm: '什么值得买', boss: 'Boss直聘', ctrip: '携程', xiaoyuzhou: '小宇宙', - hackernews: 'Hacker News', bbc: 'BBC', reuters: 'Reuters', - twitter: 'Twitter/X', reddit: 'Reddit', youtube: 'YouTube', - linkedin: 'LinkedIn', 'yahoo-finance': 'Yahoo Finance', barchart: 'Barchart', -} - -// site → ordered list of presets -const COMMANDS_BY_SITE: Record = {} -for (const p of OPENCLI_PRESETS) { - if (!COMMANDS_BY_SITE[p.site]) COMMANDS_BY_SITE[p.site] = [] - COMMANDS_BY_SITE[p.site].push(p) -} - -// Groups for the site — order matches preset group order -const SITE_GROUPS = [ - { label: '🇨🇳 国内', sites: ['xiaohongshu','bilibili','zhihu','weibo','v2ex','xueqiu','smzdm','boss','ctrip','xiaoyuzhou'] }, - { label: '🌐 Public', sites: ['hackernews','bbc','reuters'] }, - { label: '🌍 Global', sites: ['twitter','reddit','youtube','linkedin','yahoo-finance','barchart'] }, -] - -// Args list with per-key hint text and dropdown for adding known parameters -function ArgsKVList({ - pairs, - onChange, - hints, -}: { - pairs: KVPair[] - onChange: (pairs: KVPair[]) => void - hints?: Record -}) { - const update = (i: number, field: 'key' | 'value', v: string) => - onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) - const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) - - // Hint keys not yet added — shown as dropdown options - const usedKeys = new Set(pairs.map((p) => p.key)) - const availableKeys = hints ? Object.keys(hints).filter((k) => !usedKeys.has(k)) : [] - - const addParam = (key: string) => { - if (key === '__custom__') { - onChange([...pairs, { key: '', value: '' }]) - } else { - onChange([...pairs, { key, value: '' }]) - } - } - - return ( -
- {pairs.map((p, i) => { - const hintText = hints?.[p.key] - return ( -
-
+ > + + +
+ ))} + +
+ ) +} + +function kvToObj(pairs: KVPair[]): Record { + return Object.fromEntries(pairs.filter((p) => p.key).map((p) => [p.key, p.value])) +} + +function objToKv(obj: Record | undefined): KVPair[] { + if (!obj) return [] + return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })) +} + +// ── Per-channel config forms ────────────────────────────────────────────────── + +function RSSConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + return ( +
+ + onChange({ ...config, feed_url: v })} + placeholder="https://hnrss.org/frontpage" + required + /> + +
+ + onChange({ ...config, max_entries: v === '' ? undefined : v })} + placeholder="50" + min={1} + /> + + + onChange({ ...config, timeout: v === '' ? undefined : v })} + placeholder="30" + min={1} + /> + +
+
+ ) +} + +function APIConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const auth = (config.auth as Record) ?? {} + const authType = auth.type ?? 'none' + const [params, setParams] = useState(objToKv(config.params as Record)) + const [headers, setHeaders] = useState(objToKv(config.headers as Record)) + + const update = (patch: Partial>) => onChange({ ...config, ...patch }) + + const updateParams = (pairs: KVPair[]) => { + setParams(pairs) + update({ params: kvToObj(pairs) }) + } + + const updateHeaders = (pairs: KVPair[]) => { + setHeaders(pairs) + update({ headers: kvToObj(pairs) }) + } + + const updateAuth = (patch: Partial>) => + update({ auth: { ...auth, ...patch } }) + + return ( +
+ + update({ base_url: v })} + placeholder="https://api.github.com" + required + /> + +
+ + update({ endpoint: v })} + placeholder="/repos/owner/repo/issues" + required + /> + + + update({ method: v })} + options={['GET', 'POST', 'PUT', 'PATCH'].map((m) => ({ value: m, label: m }))} + /> + + + update({ result_path: v })} + placeholder="data.items" + /> + +
+ + + update({ auth: { type: v } })} + options={[ + { value: 'none', label: t('channelConfig.authNone') }, + { value: 'bearer', label: t('channelConfig.authBearer') }, + { value: 'basic', label: t('channelConfig.authBasic') }, + { value: 'api_key', label: t('channelConfig.authApiKey') }, + ]} + /> + + + {authType === 'bearer' && ( + + updateAuth({ token_env: v })} + placeholder="GITHUB_TOKEN" + /> + + )} + {authType === 'basic' && ( +
+ + updateAuth({ username: v })} + placeholder="{{secret:API_USER}}" + /> + + + updateAuth({ password: v })} + placeholder="{{secret:API_PASS}}" + /> + +
+ )} + {authType === 'api_key' && ( +
+ + updateAuth({ header: v })} + placeholder="X-API-Key" + /> + + + updateAuth({ key_env: v })} + placeholder="MY_API_KEY" + /> + +
+ )} + + + + + + + + + update({ timeout: v === '' ? undefined : v })} + placeholder="30" + min={1} + /> + +
+ ) +} + +function WebScraperConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const [selectors, setSelectors] = useState( + objToKv(config.selectors as Record), + ) + + const update = (patch: Partial>) => onChange({ ...config, ...patch }) + + const updateSelectors = (pairs: KVPair[]) => { + setSelectors(pairs) + update({ selectors: kvToObj(pairs) }) + } + + return ( +
+ + update({ url: v })} + placeholder="https://news.ycombinator.com" + required + /> + + + update({ list_selector: v })} + placeholder=".athing" + /> + + + + + + update({ timeout: v === '' ? undefined : v })} + placeholder="30" + min={1} + /> + +
+ ) +} + +function CLIConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const cmdArr = (config.command as string[]) ?? [] + const [cmdStr, setCmdStr] = useState(cmdArr.join(' ')) + const [defaults, setDefaults] = useState( + objToKv(config.defaults as Record), + ) + const [envVars, setEnvVars] = useState( + objToKv(config.env as Record), + ) + + const update = (patch: Partial>) => onChange({ ...config, ...patch }) + + const updateCmd = (v: string) => { + setCmdStr(v) + // Split respecting quoted strings + const parts = v.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] + update({ command: parts }) + } + + const updateDefaults = (pairs: KVPair[]) => { + setDefaults(pairs) + update({ defaults: kvToObj(pairs) }) + } + + const updateEnv = (pairs: KVPair[]) => { + setEnvVars(pairs) + update({ env: kvToObj(pairs) }) + } + + return ( +
+ + update({ binary: v })} + placeholder="curl" + required + /> + + + + + + update({ output_format: v })} + options={[ + { value: 'json', label: t('channelConfig.outputJson') }, + { value: 'text', label: t('channelConfig.outputText') }, + ]} + /> + + + + + + + + + update({ timeout: v === '' ? undefined : v })} + placeholder="60" + min={1} + /> + +
+ ) +} + +// ── OpenCLI presets ────────────────────────────────────────────────────────── + +type Preset = { + label: string + group: string + site: string + command: string + args: Record + /** Placeholder/description shown for each arg value input */ + argHints?: Record +} + +const OPENCLI_PRESETS: Preset[] = [ + // ── 国内 (Chinese, login required) ─────────────────────────────────────── + // Fields: rank, title, author, likes, url + { group: '🇨🇳 国内', label: '小红书 · 搜索', site: 'xiaohongshu', command: 'search', + args: { keyword: '', limit: '20' }, + argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, + // Fields: id, title, type, likes, url + { group: '🇨🇳 国内', label: '小红书 · 用户笔记', site: 'xiaohongshu', command: 'user', + args: { id: '', limit: '20' }, + argHints: { id: '用户 ID(从主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, + // Fields: rank, title, author, play, danmaku + { group: '🇨🇳 国内', label: 'Bilibili · 热门视频', site: 'bilibili', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, author, score, url + { group: '🇨🇳 国内', label: 'Bilibili · 排行榜', site: 'bilibili', command: 'ranking', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: id, author, text, likes, url + { group: '🇨🇳 国内', label: 'Bilibili · 关注动态', site: 'bilibili', command: 'dynamic', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, author, plays, url + { group: '🇨🇳 国内', label: 'Bilibili · 收藏夹', site: 'bilibili', command: 'favorite', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, plays, likes, date, url + { group: '🇨🇳 国内', label: 'Bilibili · 用户视频', site: 'bilibili', command: 'user-videos', + args: { uid: '', limit: '20' }, + argHints: { uid: 'UP 主 UID(从个人主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, + // Fields: rank, title, heat, answers, url + { group: '🇨🇳 国内', label: '知乎 · 热榜', site: 'zhihu', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, author, votes, content + { group: '🇨🇳 国内', label: '知乎 · 问题回答', site: 'zhihu', command: 'question', + args: { id: '', limit: '10' }, + argHints: { id: '问题 ID(从 URL 中获取,如 /question/123456789)', limit: '返回答案数(默认 10)' } }, + // Fields: rank, word(→title), hot_value, category, label, url + { group: '🇨🇳 国内', label: '微博 · 热搜', site: 'weibo', command: 'hot', + args: {}, + argHints: {} }, + // Fields: rank, title, score, author, url + { group: '🇨🇳 国内', label: 'V2EX · 热门话题', site: 'v2ex', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, score, author, url + { group: '🇨🇳 国内', label: 'V2EX · 最新话题', site: 'v2ex', command: 'latest', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, author, text(→content), likes, url + { group: '🇨🇳 国内', label: '雪球 · 动态', site: 'xueqiu', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, symbol, name(→title), price, changePercent, heat + { group: '🇨🇳 国内', label: '雪球 · 热门股票', site: 'xueqiu', command: 'hot-stock', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20,最大 50)' } }, + // Fields: name(→title), symbol, price, changePercent, marketCap + { group: '🇨🇳 国内', label: '雪球 · 股票行情', site: 'xueqiu', command: 'stock', + args: { symbol: '601318' }, + argHints: { symbol: 'A 股代码(如 601318 中国平安)或港股(如 00700 腾讯)' } }, + // Fields: rank, title, price, mall, comments, url + { group: '🇨🇳 国内', label: '什么值得买 · 搜索', site: 'smzdm', command: 'search', + args: { keyword: '', limit: '20' }, + argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, + // Fields: name(→title), salary, company, area, experience, degree, skills, boss, url + { group: '🇨🇳 国内', label: 'Boss直聘 · 职位搜索', site: 'boss', command: 'search', + args: { keyword: '', city: '101010100', limit: '20' }, + argHints: { keyword: '职位名称或关键词(必填,如 "前端工程师")', city: '城市代码(101010100=北京,101020100=上海,101280100=广州,101280600=深圳)', limit: '返回条数(默认 20)' } }, + // Fields: rank, name(→title), type, score, price, url + { group: '🇨🇳 国内', label: '携程 · 目的地搜索', site: 'ctrip', command: 'search', + args: { query: '', limit: '15' }, + argHints: { query: '目的地或景点名称(必填,如 "三亚")', limit: '返回条数(默认 15)' } }, + // Fields: title, author, description(→content), subscribers, episodes, updated + { group: '🇨🇳 国内', label: '小宇宙 · 播客信息', site: 'xiaoyuzhou', command: 'podcast', + args: { id: '' }, + argHints: { id: '播客 ID(从 URL 获取,如 5e280fbd418a84a0463d3e3b)' } }, + // Fields: eid, title, duration, plays, date + { group: '🇨🇳 国内', label: '小宇宙 · 单集列表', site: 'xiaoyuzhou', command: 'podcast-episodes', + args: { id: '', limit: '15' }, + argHints: { id: '播客 ID(同上)', limit: '返回集数(最多 15,受 SSR 限制)' } }, + + // ── Public (no login required) ──────────────────────────────────────────── + // Fields: rank, title, score, author, comments, url + { group: '🌐 Public', label: 'Hacker News · top stories', site: 'hackernews', command: 'top', + args: { limit: '20' }, + argHints: { limit: '返回条数(1–500)' } }, + // Fields: rank, title, description, url + { group: '🌐 Public', label: 'BBC · latest news', site: 'bbc', command: 'news', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, date, section, url + { group: '🌐 Public', label: 'Reuters · search', site: 'reuters', command: 'search', + args: { query: 'technology', limit: '20' }, + argHints: { query: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, + + // ── Global (login required) ─────────────────────────────────────────────── + // Fields: rank, topic(→title), tweets + { group: '🌍 Global', label: 'Twitter/X · trending', site: 'twitter', command: 'trending', + args: {}, + argHints: {} }, + // Fields: id, author, text(→content), likes, retweets, replies, views, created_at, url + { group: '🌍 Global', label: 'Twitter/X · timeline', site: 'twitter', command: 'timeline', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: id, author, text(→content), likes, views, url + { group: '🌍 Global', label: 'Twitter/X · search', site: 'twitter', command: 'search', + args: { query: '', limit: '20' }, + argHints: { query: '搜索关键词,支持运算符(必填,如 "AI lang:en")', limit: '返回条数(默认 20)' } }, + // Fields: title, subreddit, score, comments, url + { group: '🌍 Global', label: 'Twitter/X · bookmarks', site: 'twitter', command: 'bookmarks', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: title, subreddit, author, upvotes, comments, url + { group: '🌍 Global', label: 'Reddit · frontpage', site: 'reddit', command: 'frontpage', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, subreddit, score, comments, url + { group: '🌍 Global', label: 'Reddit · hot', site: 'reddit', command: 'hot', + args: { limit: '20' }, + argHints: { subreddit: '子版块名称(可选,留空则为全站热门,如 "programming")', limit: '返回条数(默认 20)' } }, + // Fields: title, subreddit, score, comments, url + { group: '🌍 Global', label: 'Reddit · saved posts', site: 'reddit', command: 'saved', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, channel(→author), views, duration, url + { group: '🌍 Global', label: 'YouTube · search', site: 'youtube', command: 'search', + args: { query: 'technology', limit: '10' }, + argHints: { query: '搜索关键词(必填)', limit: '返回条数(最多 10)' } }, + // Fields: rank, title, company, location, listed(→published_at), salary, url + { group: '🌍 Global', label: 'LinkedIn · job search', site: 'linkedin', command: 'search', + args: { query: 'AI engineer', limit: '20' }, + argHints: { query: '职位名称或关键词(必填)', limit: '返回条数(默认 20)' } }, + // Fields: symbol, name(→title), price, change, changePercent, open, high, low, volume, marketCap + { group: '🌍 Global', label: 'Yahoo Finance · quote', site: 'yahoo-finance', command: 'quote', + args: { symbol: 'AAPL' }, + argHints: { symbol: '股票代码(如 AAPL、GOOGL、TSLA、SPY)' } }, + // Fields: symbol, name(→title), price, change, changePct, peRatio, eps, marketCap + { group: '🌍 Global', label: 'Barchart · stock quote', site: 'barchart', command: 'quote', + args: { symbol: 'AAPL' }, + argHints: { symbol: '股票代码(如 AAPL、SPY、QQQ)' } }, +] + +const PRESET_DEFAULT = OPENCLI_PRESETS[0] + +// ── Derived lookup structures ───────────────────────────────────────────────── + +const SITE_LABELS: Record = { + xiaohongshu: '小红书', bilibili: 'Bilibili', zhihu: '知乎', + weibo: '微博', v2ex: 'V2EX', xueqiu: '雪球', + smzdm: '什么值得买', boss: 'Boss直聘', ctrip: '携程', xiaoyuzhou: '小宇宙', + hackernews: 'Hacker News', bbc: 'BBC', reuters: 'Reuters', + twitter: 'Twitter/X', reddit: 'Reddit', youtube: 'YouTube', + linkedin: 'LinkedIn', 'yahoo-finance': 'Yahoo Finance', barchart: 'Barchart', +} + +// site → ordered list of presets +const COMMANDS_BY_SITE: Record = {} +for (const p of OPENCLI_PRESETS) { + if (!COMMANDS_BY_SITE[p.site]) COMMANDS_BY_SITE[p.site] = [] + COMMANDS_BY_SITE[p.site].push(p) +} + +// Groups for the site — order matches preset group order +const SITE_GROUPS = [ + { label: '🇨🇳 国内', sites: ['xiaohongshu','bilibili','zhihu','weibo','v2ex','xueqiu','smzdm','boss','ctrip','xiaoyuzhou'] }, + { label: '🌐 Public', sites: ['hackernews','bbc','reuters'] }, + { label: '🌍 Global', sites: ['twitter','reddit','youtube','linkedin','yahoo-finance','barchart'] }, +] + +// Args list with per-key hint text and dropdown for adding known parameters +function ArgsKVList({ + pairs, + onChange, + hints, +}: { + pairs: KVPair[] + onChange: (pairs: KVPair[]) => void + hints?: Record +}) { + const update = (i: number, field: 'key' | 'value', v: string) => + onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) + const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) + + // Hint keys not yet added — shown as dropdown options + const usedKeys = new Set(pairs.map((p) => p.key)) + const availableKeys = hints ? Object.keys(hints).filter((k) => !usedKeys.has(k)) : [] + + const addParam = (key: string) => { + if (key === '__custom__') { + onChange([...pairs, { key: '', value: '' }]) + } else { + onChange([...pairs, { key, value: '' }]) + } + } + + return ( +
+ {pairs.map((p, i) => { + const hintText = hints?.[p.key] + return ( +
+
update(i, 'key', e.target.value)} - placeholder="参数名" - /> + placeholder="参数名" + /> update(i, 'value', e.target.value)} - placeholder={hintText ?? '参数值'} - /> + placeholder={hintText ?? '参数值'} + /> -
- {hintText && ( -

{hintText}

- )} -
- ) - })} - {availableKeys.length > 0 ? ( + > + + +
+ {hintText && ( +

{hintText}

+ )} +
+ ) + })} + {availableKeys.length > 0 ? ( - ) : ( - - )} -
- ) -} - -function OpenCLIConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const [args, setArgs] = useState(objToKv(config.args as Record)) - - const currentSite = (config.site as string) ?? '' - const currentCommand = (config.command as string) ?? '' - const siteCommands = COMMANDS_BY_SITE[currentSite] ?? [] - const currentPreset = siteCommands.find((p) => p.command === currentCommand) - - const applyPreset = (preset: Preset) => { - const newPairs = objToKv(preset.args) - setArgs(newPairs) - onChange({ site: preset.site, command: preset.command, args: preset.args, format: config.format ?? 'json' }) - } - - const onSiteChange = (site: string) => { - const cmds = COMMANDS_BY_SITE[site] - if (cmds?.length) { - applyPreset(cmds[0]) - } else { - onChange({ ...config, site, command: '' }) - } - } - - const onCommandChange = (command: string) => { - const preset = siteCommands.find((p) => p.command === command) - if (preset) applyPreset(preset) - } - - const updateArgs = (pairs: KVPair[]) => { - setArgs(pairs) - onChange({ ...config, args: kvToObj(pairs) }) - } - - // Strip site prefix from label for command option text - const commandOptionLabel = (p: Preset) => { - const parts = p.label.split(' · ') - return parts.length > 1 ? parts.slice(1).join(' · ') : p.command - } - - return ( -
-
+ onChange={(e) => { if (e.target.value) addParam(e.target.value) }} + > + + {availableKeys.map((k) => ( + + ))} + + + ) : ( + + )} +
+ ) +} + +function OpenCLIConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const [args, setArgs] = useState(objToKv(config.args as Record)) + + const currentSite = (config.site as string) ?? '' + const currentCommand = (config.command as string) ?? '' + const siteCommands = COMMANDS_BY_SITE[currentSite] ?? [] + const currentPreset = siteCommands.find((p) => p.command === currentCommand) + + const applyPreset = (preset: Preset) => { + const newPairs = objToKv(preset.args) + setArgs(newPairs) + onChange({ site: preset.site, command: preset.command, args: preset.args, format: config.format ?? 'json' }) + } + + const onSiteChange = (site: string) => { + const cmds = COMMANDS_BY_SITE[site] + if (cmds?.length) { + applyPreset(cmds[0]) + } else { + onChange({ ...config, site, command: '' }) + } + } + + const onCommandChange = (command: string) => { + const preset = siteCommands.find((p) => p.command === command) + if (preset) applyPreset(preset) + } + + const updateArgs = (pairs: KVPair[]) => { + setArgs(pairs) + onChange({ ...config, args: kvToObj(pairs) }) + } + + // Strip site prefix from label for command option text + const commandOptionLabel = (p: Preset) => { + const parts = p.label.split(' · ') + return parts.length > 1 ? parts.slice(1).join(' · ') : p.command + } + + return ( +
+
- + - -
- - {args.length > 0 && ( - - - - )} - - {args.length === 0 && currentCommand && ( -

{t('channelConfig.noArgs')}

- )} - - - onChange({ ...config, format: v })} - options={[ - { value: 'json', label: 'JSON(推荐)' }, - { value: 'table', label: 'Table' }, - { value: 'yaml', label: 'YAML' }, - { value: 'md', label: 'Markdown' }, - { value: 'csv', label: 'CSV' }, - ]} - /> - - -
- ) -} - -// Standard fields actually populated for each site:command -// (title/url/content/author/published_at — source_id is always injected by pipeline) -export const SITE_STANDARD_FIELDS: Record = { - 'xiaohongshu:search': ['title', 'author', 'url'], - 'xiaohongshu:user': ['title', 'url'], - 'bilibili:hot': ['title', 'author'], - 'bilibili:ranking': ['title', 'author', 'url'], - 'bilibili:dynamic': ['content', 'author', 'url'], - 'bilibili:favorite': ['title', 'author', 'url'], - 'bilibili:user-videos': ['title', 'url', 'published_at'], - 'zhihu:hot': ['title', 'url'], - 'zhihu:question': ['content', 'author'], - 'weibo:hot': ['title', 'url'], - 'v2ex:hot': ['title', 'author', 'url'], - 'v2ex:latest': ['title', 'author', 'url'], - 'xueqiu:hot': ['content', 'author', 'url'], - 'xueqiu:hot-stock': ['title'], - 'xueqiu:stock': ['title'], - 'smzdm:search': ['title', 'url'], - 'boss:search': ['title', 'url'], - 'ctrip:search': ['title', 'url'], - 'xiaoyuzhou:podcast': ['title', 'author', 'content', 'published_at'], - 'xiaoyuzhou:podcast-episodes': ['title', 'published_at'], - 'hackernews:top': ['title', 'author', 'url'], - 'bbc:news': ['title', 'content', 'url'], - 'reuters:search': ['title', 'url', 'published_at'], - 'twitter:trending': ['title'], - 'twitter:timeline': ['content', 'author', 'url', 'published_at'], - 'twitter:search': ['content', 'author', 'url'], - 'twitter:bookmarks': ['title', 'url'], - 'reddit:frontpage': ['title', 'author', 'url'], - 'reddit:hot': ['title', 'url'], - 'reddit:saved': ['title', 'url'], - 'youtube:search': ['title', 'author', 'url'], - 'linkedin:search': ['title', 'url', 'published_at'], - 'yahoo-finance:quote': ['title'], - 'barchart:quote': ['title'], -} - -// Extra fields per site:command that fall through to normalized_data as extra_* -// (fields mapped to standard title/url/content/author/published_at are excluded) -export const SITE_EXTRA_FIELDS: Record = { - 'xiaohongshu:search': ['rank', 'likes'], - 'xiaohongshu:user': ['id', 'type', 'likes'], - 'bilibili:hot': ['rank', 'play', 'danmaku'], - 'bilibili:ranking': ['rank', 'score'], - 'bilibili:dynamic': ['id', 'likes'], - 'bilibili:favorite': ['rank', 'plays'], - 'bilibili:user-videos': ['rank', 'plays', 'likes'], - 'zhihu:hot': ['rank', 'heat', 'answers'], - 'zhihu:question': ['rank', 'votes'], - 'weibo:hot': ['rank', 'hot_value', 'category', 'label'], - 'v2ex:hot': ['rank', 'score'], - 'v2ex:latest': ['rank', 'score'], - 'xueqiu:hot': ['rank', 'likes'], - 'xueqiu:hot-stock': ['rank', 'symbol', 'price', 'changePercent', 'heat'], - 'xueqiu:stock': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], - 'smzdm:search': ['rank', 'price', 'mall', 'comments'], - 'boss:search': ['salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss'], - 'ctrip:search': ['rank', 'type', 'score', 'price'], - 'xiaoyuzhou:podcast': ['subscribers', 'episodes'], - 'xiaoyuzhou:podcast-episodes': ['eid', 'duration', 'plays'], - 'hackernews:top': ['rank', 'score', 'comments'], - 'bbc:news': ['rank'], - 'reuters:search': ['rank', 'section'], - 'twitter:trending': ['rank', 'tweets'], - 'twitter:timeline': ['id', 'likes', 'retweets', 'replies', 'views'], - 'twitter:search': ['id', 'likes', 'views'], - 'twitter:bookmarks': ['score', 'comments'], - 'reddit:frontpage': ['subreddit', 'upvotes', 'comments'], - 'reddit:hot': ['rank', 'subreddit', 'score', 'comments'], - 'reddit:saved': ['subreddit', 'score', 'comments'], - 'youtube:search': ['rank', 'views', 'duration'], - 'linkedin:search': ['rank', 'company', 'location', 'salary'], - 'yahoo-finance:quote': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], - 'barchart:quote': ['symbol', 'price', 'change', 'changePct', 'peRatio', 'eps', 'marketCap'], -} - -export { OPENCLI_PRESETS, PRESET_DEFAULT, SITE_LABELS, COMMANDS_BY_SITE } - -// ── Public component ────────────────────────────────────────────────────────── - -export type ChannelType = 'rss' | 'api' | 'web_scraper' | 'cli' | 'opencli' - -interface Props { - channelType: ChannelType - config: Record - onChange: (config: Record) => void -} - -export default function ChannelConfigForm({ channelType, config, onChange }: Props) { - - switch (channelType) { - case 'rss': - return - case 'api': - return - case 'web_scraper': - return - case 'cli': - return - case 'opencli': - return - } -} + onChange={(e) => onCommandChange(e.target.value)} + disabled={!currentSite || siteCommands.length === 0} + > + + {siteCommands.map((p) => ( + + ))} + + +
+ + {args.length > 0 && ( + + + + )} + + {args.length === 0 && currentCommand && ( +

{t('channelConfig.noArgs')}

+ )} + + + onChange({ ...config, format: v })} + options={[ + { value: 'json', label: 'JSON(推荐)' }, + { value: 'table', label: 'Table' }, + { value: 'yaml', label: 'YAML' }, + { value: 'md', label: 'Markdown' }, + { value: 'csv', label: 'CSV' }, + ]} + /> + + +
+ ) +} + +// Standard fields actually populated for each site:command +// (title/url/content/author/published_at — source_id is always injected by pipeline) +export const SITE_STANDARD_FIELDS: Record = { + 'xiaohongshu:search': ['title', 'author', 'url'], + 'xiaohongshu:user': ['title', 'url'], + 'bilibili:hot': ['title', 'author'], + 'bilibili:ranking': ['title', 'author', 'url'], + 'bilibili:dynamic': ['content', 'author', 'url'], + 'bilibili:favorite': ['title', 'author', 'url'], + 'bilibili:user-videos': ['title', 'url', 'published_at'], + 'zhihu:hot': ['title', 'url'], + 'zhihu:question': ['content', 'author'], + 'weibo:hot': ['title', 'url'], + 'v2ex:hot': ['title', 'author', 'url'], + 'v2ex:latest': ['title', 'author', 'url'], + 'xueqiu:hot': ['content', 'author', 'url'], + 'xueqiu:hot-stock': ['title'], + 'xueqiu:stock': ['title'], + 'smzdm:search': ['title', 'url'], + 'boss:search': ['title', 'url'], + 'ctrip:search': ['title', 'url'], + 'xiaoyuzhou:podcast': ['title', 'author', 'content', 'published_at'], + 'xiaoyuzhou:podcast-episodes': ['title', 'published_at'], + 'hackernews:top': ['title', 'author', 'url'], + 'bbc:news': ['title', 'content', 'url'], + 'reuters:search': ['title', 'url', 'published_at'], + 'twitter:trending': ['title'], + 'twitter:timeline': ['content', 'author', 'url', 'published_at'], + 'twitter:search': ['content', 'author', 'url'], + 'twitter:bookmarks': ['title', 'url'], + 'reddit:frontpage': ['title', 'author', 'url'], + 'reddit:hot': ['title', 'url'], + 'reddit:saved': ['title', 'url'], + 'youtube:search': ['title', 'author', 'url'], + 'linkedin:search': ['title', 'url', 'published_at'], + 'yahoo-finance:quote': ['title'], + 'barchart:quote': ['title'], +} + +// Extra fields per site:command that fall through to normalized_data as extra_* +// (fields mapped to standard title/url/content/author/published_at are excluded) +export const SITE_EXTRA_FIELDS: Record = { + 'xiaohongshu:search': ['rank', 'likes'], + 'xiaohongshu:user': ['id', 'type', 'likes'], + 'bilibili:hot': ['rank', 'play', 'danmaku'], + 'bilibili:ranking': ['rank', 'score'], + 'bilibili:dynamic': ['id', 'likes'], + 'bilibili:favorite': ['rank', 'plays'], + 'bilibili:user-videos': ['rank', 'plays', 'likes'], + 'zhihu:hot': ['rank', 'heat', 'answers'], + 'zhihu:question': ['rank', 'votes'], + 'weibo:hot': ['rank', 'hot_value', 'category', 'label'], + 'v2ex:hot': ['rank', 'score'], + 'v2ex:latest': ['rank', 'score'], + 'xueqiu:hot': ['rank', 'likes'], + 'xueqiu:hot-stock': ['rank', 'symbol', 'price', 'changePercent', 'heat'], + 'xueqiu:stock': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], + 'smzdm:search': ['rank', 'price', 'mall', 'comments'], + 'boss:search': ['salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss'], + 'ctrip:search': ['rank', 'type', 'score', 'price'], + 'xiaoyuzhou:podcast': ['subscribers', 'episodes'], + 'xiaoyuzhou:podcast-episodes': ['eid', 'duration', 'plays'], + 'hackernews:top': ['rank', 'score', 'comments'], + 'bbc:news': ['rank'], + 'reuters:search': ['rank', 'section'], + 'twitter:trending': ['rank', 'tweets'], + 'twitter:timeline': ['id', 'likes', 'retweets', 'replies', 'views'], + 'twitter:search': ['id', 'likes', 'views'], + 'twitter:bookmarks': ['score', 'comments'], + 'reddit:frontpage': ['subreddit', 'upvotes', 'comments'], + 'reddit:hot': ['rank', 'subreddit', 'score', 'comments'], + 'reddit:saved': ['subreddit', 'score', 'comments'], + 'youtube:search': ['rank', 'views', 'duration'], + 'linkedin:search': ['rank', 'company', 'location', 'salary'], + 'yahoo-finance:quote': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], + 'barchart:quote': ['symbol', 'price', 'change', 'changePct', 'peRatio', 'eps', 'marketCap'], +} + +export { OPENCLI_PRESETS, PRESET_DEFAULT, SITE_LABELS, COMMANDS_BY_SITE } + +// ── Public component ────────────────────────────────────────────────────────── + +export type ChannelType = 'rss' | 'api' | 'web_scraper' | 'cli' | 'opencli' + +interface Props { + channelType: ChannelType + config: Record + onChange: (config: Record) => void +} + +export default function ChannelConfigForm({ channelType, config, onChange }: Props) { + + switch (channelType) { + case 'rss': + return + case 'api': + return + case 'web_scraper': + return + case 'cli': + return + case 'opencli': + return + } +} diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 00000000..90587f07 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Command } from 'cmdk' +import { + Bell, + Bot, + Database, + FileText, + Gauge, + KeyRound, + ListChecks, + Network, + Search, + Server, + Workflow, + X, +} from 'lucide-react' + +interface CommandAction { + id: string + label: string + hint: string + keywords: string[] + to: string + icon: typeof Gauge +} + +export default function CommandPalette() { + const { t } = useTranslation() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + const actions = useMemo( + () => [ + { + id: 'dashboard', + label: t('nav.dashboard'), + hint: 'overview health stats', + keywords: ['dashboard', 'overview', '仪表盘', '概览'], + to: '/dashboard', + icon: Gauge, + }, + { + id: 'topology', + label: t('nav.topology'), + hint: 'node graph data flow', + keywords: ['topology', 'graph', 'node', 'flow', '拓扑', '节点'], + to: '/topology', + icon: Workflow, + }, + { + id: 'records', + label: t('nav.records'), + hint: 'collected data notebook', + keywords: ['records', 'data', 'notes', '采集记录', '笔记', '数据'], + to: '/records', + icon: FileText, + }, + { + id: 'tasks', + label: t('nav.tasks'), + hint: 'runs failures events', + keywords: ['tasks', 'runs', 'failed', '任务', '失败', '运行'], + to: '/tasks', + icon: ListChecks, + }, + { + id: 'sources', + label: t('nav.sources'), + hint: 'channels feeds sites', + keywords: ['sources', 'channels', 'feeds', '数据源', '来源'], + to: '/sources', + icon: Database, + }, + { + id: 'nodes', + label: t('nav.browsers'), + hint: 'edge collection nodes', + keywords: ['nodes', 'browser', 'agent', '采集节点', '浏览器'], + to: '/nodes', + icon: Network, + }, + { + id: 'agents', + label: t('nav.agents'), + hint: 'ai processors prompts', + keywords: ['agents', 'ai', 'prompt', '智能体'], + to: '/agents', + icon: Bot, + }, + { + id: 'providers', + label: t('nav.providers'), + hint: 'model providers keys', + keywords: ['providers', 'models', 'keys', '模型', '提供商'], + to: '/providers', + icon: KeyRound, + }, + { + id: 'notifications', + label: t('nav.notifications'), + hint: 'webhook ack delivery', + keywords: ['notifications', 'webhook', 'ack', '通知', '回执'], + to: '/notifications', + icon: Bell, + }, + { + id: 'workers', + label: t('nav.workers'), + hint: 'celery workers chrome pool', + keywords: ['workers', 'celery', 'chrome', '工作节点'], + to: '/workers', + icon: Server, + }, + ], + [t], + ) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + setOpen((value) => !value) + } + if (event.key === 'Escape') { + setOpen(false) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + const run = (to: string) => { + navigate(to) + setOpen(false) + } + + if (!open) return null + + return ( +
setOpen(false)} + > + event.stopPropagation()} + > +
+ + + +
+ + + {t('command.empty')} + + + {actions.map((action) => { + const Icon = action.icon + return ( + run(action.to)} + className="flex cursor-pointer items-center gap-3 border border-transparent px-3 py-2.5 text-sm text-zinc-300 aria-selected:border-primary-500/50 aria-selected:bg-primary-500/15 aria-selected:text-white" + > + + {action.label} + + {action.hint} + + + ) + })} + + +
+ {t('command.footer')} + Esc +
+
+
+ ) +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index 1bbf7b54..e766bb4f 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -1,58 +1,58 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { cn } from '@/lib/utils' -import { buttonVariants } from '@/components/ui/button' - -interface ConfirmDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - title: string - description?: string - confirmLabel?: string - variant?: 'destructive' | 'default' - onConfirm: () => void -} - -export default function ConfirmDialog({ - open, - onOpenChange, - title, - description, - confirmLabel = '确认删除', - variant = 'destructive', - onConfirm, -}: ConfirmDialogProps) { - return ( - - - - {title} - {description && ( - {description} - )} - - - 取消 - - {confirmLabel} - - - - - ) -} +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + confirmLabel?: string + variant?: 'destructive' | 'default' + onConfirm: () => void +} + +export default function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = '确认删除', + variant = 'destructive', + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + {title} + {description && ( + {description} + )} + + + 取消 + + {confirmLabel} + + + + + ) +} diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 845de263..ccf61434 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -1,60 +1,60 @@ -interface Column { - key: string - header: string - render: (row: T) => React.ReactNode - width?: string -} - -interface Props { - columns: Column[] - data: T[] - keyFn: (row: T) => string - emptyMessage?: string - emptyComponent?: React.ReactNode -} - -export default function DataTable({ columns, data, keyFn, emptyMessage = 'No data', emptyComponent }: Props) { - return ( -
- - - - {columns.map((col) => ( - - ))} - - - - {data.length === 0 ? ( - - - - ) : ( - data.map((row) => ( - - {columns.map((col) => ( - - ))} - - )) - )} - -
- {col.header} -
- {emptyComponent ?? ( -
{emptyMessage}
- )} -
- {col.render(row)} -
-
- ) -} +interface Column { + key: string + header: string + render: (row: T) => React.ReactNode + width?: string +} + +interface Props { + columns: Column[] + data: T[] + keyFn: (row: T) => string + emptyMessage?: string + emptyComponent?: React.ReactNode +} + +export default function DataTable({ columns, data, keyFn, emptyMessage = 'No data', emptyComponent }: Props) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((row) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
+ {col.header} +
+ {emptyComponent ?? ( +
{emptyMessage}
+ )} +
+ {col.render(row)} +
+
+ ) +} diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx index d25f8b66..18f6d86e 100644 --- a/frontend/src/components/EmptyState.tsx +++ b/frontend/src/components/EmptyState.tsx @@ -1,38 +1,38 @@ -import type { ElementType } from 'react' - -interface EmptyStateProps { - icon?: ElementType - title: string - description?: string - action?: { label: string; onClick: () => void } -} - -export default function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { - return ( -
- {Icon && ( - - )} -

- {title} -

- {description && ( -

- {description} -

- )} - {action && ( - - )} -
- ) -} +import type { ElementType } from 'react' + +interface EmptyStateProps { + icon?: ElementType + title: string + description?: string + action?: { label: string; onClick: () => void } +} + +export default function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( +
+ {Icon && ( + + )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ) +} diff --git a/frontend/src/components/ErrorAlert.tsx b/frontend/src/components/ErrorAlert.tsx index 68a24515..aa10e839 100644 --- a/frontend/src/components/ErrorAlert.tsx +++ b/frontend/src/components/ErrorAlert.tsx @@ -8,14 +8,14 @@ interface Props { export default function ErrorAlert({ error, onRetry }: Props) { const message = error instanceof Error ? error.message : error return ( -
- +
+
-

{message}

+

{message}

{onRetry && ( diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 6997272a..c4472437 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,43 +1,43 @@ -import { Component, type ReactNode } from 'react' - -interface Props { - children: ReactNode - fallback?: ReactNode -} - -interface State { - hasError: boolean - error: Error | null -} - -export default class ErrorBoundary extends Component { - state: State = { hasError: false, error: null } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, info: { componentStack: string }) { - console.error('[ErrorBoundary]', error, info.componentStack) - } - - reset = () => this.setState({ hasError: false, error: null }) - - render() { - if (this.state.hasError) { - return this.props.fallback ?? ( -
-

页面渲染出错

-

{this.state.error?.message}

- -
- ) - } - return this.props.children - } -} +import { Component, type ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export default class ErrorBoundary extends Component { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, info: { componentStack: string }) { + console.error('[ErrorBoundary]', error, info.componentStack) + } + + reset = () => this.setState({ hasError: false, error: null }) + + render() { + if (this.state.hasError) { + return this.props.fallback ?? ( +
+

页面渲染出错

+

{this.state.error?.message}

+ +
+ ) + } + return this.props.children + } +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f1dc0409..9e54c4ff 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -24,9 +24,11 @@ import { } from 'lucide-react' import { clsx } from 'clsx' import { getDashboardStats } from '../api/endpoints' +import CommandPalette from './CommandPalette' const ROUTE_LABELS: Record = { '/dashboard': '数据看板', + '/topology': '拓扑工作台', '/sources': '数据源', '/tasks': '任务', '/records': '采集记录', @@ -43,13 +45,13 @@ function Breadcrumb() { const label = ROUTE_LABELS[pathname] return ( -
- - 首页 +
+ + HOME {label && ( <> - / - {label} + / + {label} )}
@@ -61,7 +63,7 @@ export default function Layout() { const location = useLocation() const [collapsed, setCollapsed] = useState(false) const [dark, setDark] = useState(() => { - return localStorage.getItem('theme') === 'dark' + return localStorage.getItem('theme') !== 'light' }) const { data: statsData } = useQuery({ @@ -78,17 +80,18 @@ export default function Layout() { } else { document.documentElement.classList.remove('dark') } - }, []) + }, [dark]) const NAV_ITEMS = [ { to: '/dashboard', label: t('nav.dashboard'), icon: LayoutDashboard }, + { to: '/topology', label: t('nav.topology'), icon: Network }, { to: '/sources', label: t('nav.sources'), icon: Database }, { to: '/tasks', label: t('nav.tasks'), icon: ListChecks }, { to: '/records', label: t('nav.records'), icon: FileText }, { to: '/schedules', label: t('nav.schedules'), icon: Clock }, { to: '/agents', label: t('nav.agents'), icon: Bot }, { to: '/providers', label: t('nav.providers'), icon: KeyRound }, - { to: '/nodes', label: t('nav.browsers'), icon: Network }, + { to: '/nodes', label: t('nav.browsers'), icon: Chrome }, { to: '/notifications', label: t('nav.notifications'), icon: Bell }, { to: '/workers', label: t('nav.workers'), icon: Server }, ] @@ -113,22 +116,33 @@ export default function Layout() { } return ( -
+
{/* Sidebar */} {/* Main content */} -
-
+
+
@@ -209,6 +224,7 @@ export default function Layout() {
+
) } diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index ff958176..9b56778e 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -4,7 +4,7 @@ export default function LoadingSpinner({ className }: { className?: string }) { return (
+ + update({ ack_secret: v })} + placeholder={t('notifierConfig.optional')} + type="password" + /> + -
-

{title}

+
+
+

OPS CONSOLE

+

{title}

{description && ( -

{description}

+

{description}

)}
- {action &&
{action}
} + {action &&
{action}
}
) } diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx index 97786e0d..17477afc 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/Pagination.tsx @@ -1,91 +1,91 @@ -interface PaginationProps { - page: number - pages: number - total: number - limit: number - onChange: (page: number) => void -} - -function buildPageNumbers(page: number, pages: number): (number | 'ellipsis')[] { - if (pages <= 7) { - return Array.from({ length: pages }, (_, i) => i + 1) - } - - const result: (number | 'ellipsis')[] = [] - - // Always show first page - result.push(1) - - if (page <= 4) { - // Near the start: show 1 2 3 4 5 ... N - for (let i = 2; i <= Math.min(5, pages - 1); i++) result.push(i) - result.push('ellipsis') - } else if (page >= pages - 3) { - // Near the end: show 1 ... N-4 N-3 N-2 N-1 N - result.push('ellipsis') - for (let i = Math.max(2, pages - 4); i <= pages - 1; i++) result.push(i) - } else { - // Middle: show 1 ... p-1 p p+1 ... N - result.push('ellipsis') - for (let i = page - 1; i <= page + 1; i++) result.push(i) - result.push('ellipsis') - } - - // Always show last page - result.push(pages) - - return result -} - -export default function Pagination({ page, pages, total, onChange }: PaginationProps) { - const pageNumbers = buildPageNumbers(page, pages) - - return ( -
- 共 {total} 条 - -
- {/* Prev */} - - - {/* Page numbers */} - {pageNumbers.map((p, i) => - p === 'ellipsis' ? ( - - … - - ) : ( - - ) - )} - - {/* Next */} - -
-
- ) -} +interface PaginationProps { + page: number + pages: number + total: number + limit: number + onChange: (page: number) => void +} + +function buildPageNumbers(page: number, pages: number): (number | 'ellipsis')[] { + if (pages <= 7) { + return Array.from({ length: pages }, (_, i) => i + 1) + } + + const result: (number | 'ellipsis')[] = [] + + // Always show first page + result.push(1) + + if (page <= 4) { + // Near the start: show 1 2 3 4 5 ... N + for (let i = 2; i <= Math.min(5, pages - 1); i++) result.push(i) + result.push('ellipsis') + } else if (page >= pages - 3) { + // Near the end: show 1 ... N-4 N-3 N-2 N-1 N + result.push('ellipsis') + for (let i = Math.max(2, pages - 4); i <= pages - 1; i++) result.push(i) + } else { + // Middle: show 1 ... p-1 p p+1 ... N + result.push('ellipsis') + for (let i = page - 1; i <= page + 1; i++) result.push(i) + result.push('ellipsis') + } + + // Always show last page + result.push(pages) + + return result +} + +export default function Pagination({ page, pages, total, onChange }: PaginationProps) { + const pageNumbers = buildPageNumbers(page, pages) + + return ( +
+ 共 {total} 条 + +
+ {/* Prev */} + + + {/* Page numbers */} + {pageNumbers.map((p, i) => + p === 'ellipsis' ? ( + + … + + ) : ( + + ) + )} + + {/* Next */} + +
+
+ ) +} diff --git a/frontend/src/components/SkeletonLoader.tsx b/frontend/src/components/SkeletonLoader.tsx index 6d74e973..90b60a9b 100644 --- a/frontend/src/components/SkeletonLoader.tsx +++ b/frontend/src/components/SkeletonLoader.tsx @@ -1,63 +1,63 @@ -import { Skeleton } from '@/components/ui/skeleton' - -interface TableSkeletonProps { - rows?: number -} - -export function TableSkeleton({ rows = 5 }: TableSkeletonProps) { - return ( -
- {/* Header row */} -
- - - - - - -
- {/* Data rows */} - {Array.from({ length: rows }).map((_, i) => ( -
- - - - - - -
- ))} -
- ) -} - -interface CardSkeletonProps { - cards?: number -} - -export function CardSkeleton({ cards = 4 }: CardSkeletonProps) { - return ( -
- {Array.from({ length: cards }).map((_, i) => ( -
-
- - -
- - -
- - -
-
- ))} -
- ) -} +import { Skeleton } from '@/components/ui/skeleton' + +interface TableSkeletonProps { + rows?: number +} + +export function TableSkeleton({ rows = 5 }: TableSkeletonProps) { + return ( +
+ {/* Header row */} +
+ + + + + + +
+ {/* Data rows */} + {Array.from({ length: rows }).map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ) +} + +interface CardSkeletonProps { + cards?: number +} + +export function CardSkeleton({ cards = 4 }: CardSkeletonProps) { + return ( +
+ {Array.from({ length: cards }).map((_, i) => ( +
+
+ + +
+ + +
+ + +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index e74922f2..4638bfea 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,18 +1,20 @@ import { clsx } from 'clsx' const STATUS_STYLES: Record = { - pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', - running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', - ai_processing: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', - completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', - failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', - cancelled: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', - online: 'bg-green-100 text-green-800', - offline: 'bg-gray-100 text-gray-700', - sent: 'bg-green-100 text-green-800', - raw: 'bg-gray-100 text-gray-700', - normalized: 'bg-blue-100 text-blue-800', - ai_processed: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + pending: 'border-amber-400/40 bg-amber-400/10 text-amber-200', + running: 'border-zinc-200/50 bg-zinc-100/10 text-zinc-100', + ai_processing: 'border-primary-500/50 bg-primary-500/10 text-primary-100', + completed: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-200', + failed: 'border-primary-500/60 bg-primary-500/15 text-primary-100', + cancelled: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + online: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-200', + offline: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + sent: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-200', + acked: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-200', + not_required: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + raw: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + normalized: 'border-zinc-200/50 bg-zinc-100/10 text-zinc-100', + ai_processed: 'border-primary-500/50 bg-primary-500/10 text-primary-100', } const STATUS_LABELS: Record = { @@ -26,6 +28,8 @@ const STATUS_LABELS: Record = { normalized: '已归一化', ai_processed: '已处理', sent: '已发送', + acked: '已回执', + not_required: '无需回执', online: '在线', offline: '离线', } @@ -36,12 +40,12 @@ interface Props { } export default function StatusBadge({ status, className }: Props) { - const style = STATUS_STYLES[status] ?? 'bg-gray-100 text-gray-700' + const style = STATUS_STYLES[status] ?? 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300' const label = STATUS_LABELS[status] ?? status return ( (null) - const hideTimer = useRef>() - - const open = (e: React.MouseEvent) => { - clearTimeout(hideTimer.current) - setRect(e.currentTarget.getBoundingClientRect()) - } - - const close = () => { - hideTimer.current = setTimeout(() => setRect(null), 120) - } - - const keepOpen = () => clearTimeout(hideTimer.current) - - // Compute popover position: flip above if too close to bottom of viewport - const popoverStyle = (): React.CSSProperties => { - if (!rect) return {} - const spaceBelow = window.innerHeight - rect.bottom - const popoverH = 200 - const top = spaceBelow > popoverH + 12 ? rect.bottom + 6 : rect.top - popoverH - 6 - return { - position: 'fixed', - top, - left: rect.left, - width: Math.max(rect.width, 320), - maxWidth: Math.min(480, window.innerWidth - rect.left - 12), - zIndex: 9999, - } - } - - return ( - <> -
- {text} -
- - {rect && - createPortal( -
- {text} -
, - document.body, - )} - - ) -} +import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +interface Props { + text: string + lines?: number + className?: string +} + +export default function TruncatedText({ text, lines = 2, className = '' }: Props) { + const [rect, setRect] = useState(null) + const hideTimer = useRef>() + + const open = (e: React.MouseEvent) => { + clearTimeout(hideTimer.current) + setRect(e.currentTarget.getBoundingClientRect()) + } + + const close = () => { + hideTimer.current = setTimeout(() => setRect(null), 120) + } + + const keepOpen = () => clearTimeout(hideTimer.current) + + // Compute popover position: flip above if too close to bottom of viewport + const popoverStyle = (): React.CSSProperties => { + if (!rect) return {} + const spaceBelow = window.innerHeight - rect.bottom + const popoverH = 200 + const top = spaceBelow > popoverH + 12 ? rect.bottom + 6 : rect.top - popoverH - 6 + return { + position: 'fixed', + top, + left: rect.left, + width: Math.max(rect.width, 320), + maxWidth: Math.min(480, window.innerWidth - rect.left - 12), + zIndex: 9999, + } + } + + return ( + <> +
+ {text} +
+ + {rect && + createPortal( +
+ {text} +
, + document.body, + )} + + ) +} diff --git a/frontend/src/components/opencli/MetricTile.tsx b/frontend/src/components/opencli/MetricTile.tsx new file mode 100644 index 00000000..34ec6f51 --- /dev/null +++ b/frontend/src/components/opencli/MetricTile.tsx @@ -0,0 +1,72 @@ +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type MetricTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' + +const TONE_STYLES: Record = { + neutral: { + rail: 'bg-zinc-300', + icon: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-200', + value: 'text-zinc-50', + }, + accent: { + rail: 'bg-primary-500', + icon: 'border-primary-500/40 bg-primary-500/10 text-primary-100', + value: 'text-zinc-50', + }, + success: { + rail: 'bg-emerald-400', + icon: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200', + value: 'text-emerald-100', + }, + warning: { + rail: 'bg-amber-400', + icon: 'border-amber-400/35 bg-amber-400/10 text-amber-200', + value: 'text-amber-100', + }, + danger: { + rail: 'bg-primary-500', + icon: 'border-primary-500/50 bg-primary-500/15 text-primary-100', + value: 'text-primary-100', + }, +} + +interface MetricTileProps { + label: string + value: React.ReactNode + sub?: React.ReactNode + icon?: LucideIcon + tone?: MetricTone + className?: string +} + +export function MetricTile({ + label, + value, + sub, + icon: Icon, + tone = 'neutral', + className, +}: MetricTileProps) { + const style = TONE_STYLES[tone] + + return ( +
+
+
+
+

{label}

+

+ {value} +

+
+ {Icon && ( + + + + )} +
+ {sub &&
{sub}
} +
+ ) +} diff --git a/frontend/src/components/opencli/PanelHeader.tsx b/frontend/src/components/opencli/PanelHeader.tsx new file mode 100644 index 00000000..7907569d --- /dev/null +++ b/frontend/src/components/opencli/PanelHeader.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils' + +interface PanelHeaderProps { + label: string + title: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode + className?: string +} + +export function PanelHeader({ + label, + title, + description, + actions, + className, +}: PanelHeaderProps) { + return ( +
+
+

{label}

+
{title}
+ {description &&
{description}
} +
+ {actions &&
{actions}
} +
+ ) +} diff --git a/frontend/src/components/opencli/PlaybackControls.tsx b/frontend/src/components/opencli/PlaybackControls.tsx new file mode 100644 index 00000000..d9f47f2d --- /dev/null +++ b/frontend/src/components/opencli/PlaybackControls.tsx @@ -0,0 +1,47 @@ +import { Pause, Play, SkipBack, SkipForward } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface PlaybackControlsProps { + playing: boolean + disabled?: boolean + progressLabel: string + onToggle: () => void + onPrevious: () => void + onNext: () => void + onReset: () => void + className?: string +} + +export function PlaybackControls({ + playing, + disabled, + progressLabel, + onToggle, + onPrevious, + onNext, + onReset, + className, +}: PlaybackControlsProps) { + return ( +
+ + + + + + {progressLabel} + +
+ ) +} diff --git a/frontend/src/components/opencli/index.ts b/frontend/src/components/opencli/index.ts new file mode 100644 index 00000000..677e426a --- /dev/null +++ b/frontend/src/components/opencli/index.ts @@ -0,0 +1,3 @@ +export { MetricTile } from './MetricTile' +export { PanelHeader } from './PanelHeader' +export { PlaybackControls } from './PlaybackControls' diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx index 8722561c..c713ff0b 100644 --- a/frontend/src/components/ui/alert-dialog.tsx +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -1,139 +1,139 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" - -const AlertDialog = AlertDialogPrimitive.Root - -const AlertDialogTrigger = AlertDialogPrimitive.Trigger - -const AlertDialogPortal = AlertDialogPrimitive.Portal - -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + - -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName - -const AlertDialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogHeader.displayName = "AlertDialogHeader" - -const AlertDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogFooter.displayName = "AlertDialogFooter" - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} + {...props} + /> + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index f000e3ef..a66c5098 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -1,36 +1,36 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-[2px] border px-2 py-0.5 font-telemetry text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-0", { variants: { variant: { default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + "border-primary-500/50 bg-primary-500/12 text-primary-100 hover:bg-primary-500/18", secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + "border-white/12 bg-white/[0.045] text-zinc-300 hover:bg-white/[0.075]", destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", + "border-primary-500/70 bg-primary-500/18 text-primary-50 hover:bg-primary-500/24", + outline: "border-white/14 bg-transparent text-zinc-300", }, }, - defaultVariants: { - variant: "default", - }, - } -) - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) -} - -export { Badge, badgeVariants } + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 36496a28..9c03f069 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,56 +1,57 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[2px] border font-telemetry text-[11px] font-semibold uppercase tracking-[0.12em] ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/70 focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: "border-primary-500/70 bg-primary-500/16 text-white hover:border-primary-400 hover:bg-primary-500/24", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + "border-primary-500/80 bg-primary-500/22 text-primary-50 hover:border-primary-300 hover:bg-primary-500/30", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border-white/14 bg-black/25 text-zinc-200 hover:border-white/28 hover:bg-white/[0.075] hover:text-white", secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + "border-white/10 bg-white/[0.045] text-zinc-200 hover:border-white/22 hover:bg-white/[0.08] hover:text-white", + ghost: "border-transparent bg-transparent text-zinc-400 hover:border-white/12 hover:bg-white/[0.055] hover:text-white", + link: "border-transparent bg-transparent px-0 text-primary-300 underline-offset-4 hover:text-primary-100 hover:underline", }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: "h-9 px-3 py-2", + sm: "h-8 px-2.5", + lg: "h-10 px-4", + icon: "h-9 w-9", + xs: "h-7 px-2 text-[10px]", }, }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - -export { Button, buttonVariants } + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index c680b9d3..f5801aaf 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,120 +1,120 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Dialog = DialogPrimitive.Root - -const DialogTrigger = DialogPrimitive.Trigger - -const DialogPortal = DialogPrimitive.Portal - -const DialogClose = DialogPrimitive.Close - -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
+ {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + -) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogClose, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} + {...props} + /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 68551b92..7d38451c 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,22 +1,22 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( - ) - } -) -Input.displayName = "Input" - -export { Input } + {...props} + /> + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index d826bdba..70cb43fb 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -1,158 +1,158 @@ -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { Check, ChevronDown, ChevronUp } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Select = SelectPrimitive.Root - -const SelectGroup = SelectPrimitive.Group - -const SelectValue = SelectPrimitive.Value - -const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( span]:line-clamp-1", + "flex h-9 w-full items-center justify-between rounded-[2px] border border-white/14 bg-black/35 px-3 py-2 text-sm text-zinc-100 ring-offset-background data-[placeholder]:text-zinc-600 focus:border-primary-500/70 focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} - {...props} - > - {children} - - - - -)) -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName - -const SelectScrollUpButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName - -const SelectScrollDownButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName - -const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + - - - {children} - - - - -)) -SelectContent.displayName = SelectPrimitive.Content.displayName - -const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + className + )} + position={position} + {...props} + > + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -SelectLabel.displayName = SelectPrimitive.Label.displayName - -const SelectItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -SelectItem.displayName = SelectPrimitive.Item.displayName - -const SelectSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + {...props} + > + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -SelectSeparator.displayName = SelectPrimitive.Separator.displayName - -export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, -} +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx index 6d7f1226..9864754a 100644 --- a/frontend/src/components/ui/separator.tsx +++ b/frontend/src/components/ui/separator.tsx @@ -1,29 +1,29 @@ -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" - -import { cn } from "@/lib/utils" - -const Separator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref - ) => ( - - ) -) -Separator.displayName = SeparatorPrimitive.Root.displayName - -export { Separator } +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx index 01b8b6d4..54b06edc 100644 --- a/frontend/src/components/ui/skeleton.tsx +++ b/frontend/src/components/ui/skeleton.tsx @@ -1,15 +1,15 @@ -import { cn } from "@/lib/utils" - -function Skeleton({ - className, - ...props -}: React.HTMLAttributes) { - return ( +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return (
- ) -} - -export { Skeleton } + ) +} + +export { Skeleton } diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index e1ae87b9..c0814451 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -1,30 +1,30 @@ -"use client" - -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" - -import { cn } from "@/lib/utils" - -const TooltipProvider = TooltipPrimitive.Provider - -const Tooltip = TooltipPrimitive.Root - -const TooltipTrigger = TooltipPrimitive.Trigger - -const TooltipContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + -)) -TooltipContent.displayName = TooltipPrimitive.Content.displayName - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } + {...props} + /> +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index af70239e..d4cfe657 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -3,6 +3,7 @@ import type { Translations } from './zh' const en: Translations = { nav: { dashboard: 'Dashboard', + topology: 'Topology', sources: 'Sources', tasks: 'Tasks', records: 'Records', @@ -46,6 +47,27 @@ const en: Translations = { createdAt: 'Created', updatedAt: 'Updated', }, + command: { + title: 'Command palette', + placeholder: 'Jump to records, tasks, nodes…', + empty: 'No matching action', + navigation: 'Navigation', + footer: 'Type a keyword, then press Enter', + }, + topology: { + title: 'Topology', + description: 'Runtime relationships from sources to tasks, agents, records, and notifications', + refresh: 'Refresh', + quickJump: 'Quick jump', + allNodes: 'All nodes', + running: 'Running', + needsFocus: 'Needs focus', + ready: 'Ready', + refreshing: 'Refreshing…', + edgeCount: '{{count}} edges', + selectNode: 'Select a node to inspect context', + openDetail: 'Open detail', + }, dashboard: { title: 'Dashboard', description: 'Real-time overview of your data collection system', @@ -130,6 +152,12 @@ const en: Translations = { noRules: 'No notification rules', noLogs: 'No notification logs', ruleId: 'Rule ID', + recordId: 'Record ID', + deliveryStatus: 'Delivery', + ackStatus: 'ACK', + response: 'Response', + ackDetail: 'ACK Detail', + ackedAt: 'ACK Time', errorMsg: 'Error', time: 'Time', confirmDelete: 'Delete rule "{{name}}"?', @@ -143,6 +171,8 @@ const en: Translations = { webhookUrl: 'Webhook URL', secret: 'Signing Secret', webhookSecretHint: 'Optional. Used for HMAC-SHA256 request signing (X-Signature-256 header)', + ackSecret: 'ACK Secret', + ackSecretHint: 'Optional. Downstream ACK callbacks use the same HMAC header. Falls back to signing secret when empty.', extraHeaders: 'Extra Headers', dingtalkUrlHint: 'DingTalk robot webhook URL including access_token query param', dingtalkSecretHint: 'Optional. Fill when "Add Signature" is enabled on the robot', diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 197c15f5..d1c8a0ca 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,18 +1,18 @@ -import i18n from 'i18next' -import { initReactI18next } from 'react-i18next' -import zh from './zh' -import en from './en' - -const saved = localStorage.getItem('lang') ?? 'zh' - -i18n.use(initReactI18next).init({ - resources: { - zh: { translation: zh }, - en: { translation: en }, - }, - lng: saved, - fallbackLng: 'zh', - interpolation: { escapeValue: false }, -}) - -export default i18n +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import zh from './zh' +import en from './en' + +const saved = localStorage.getItem('lang') ?? 'zh' + +i18n.use(initReactI18next).init({ + resources: { + zh: { translation: zh }, + en: { translation: en }, + }, + lng: saved, + fallbackLng: 'zh', + interpolation: { escapeValue: false }, +}) + +export default i18n diff --git a/frontend/src/i18n/zh.ts b/frontend/src/i18n/zh.ts index 3d4d8985..8601eedf 100644 --- a/frontend/src/i18n/zh.ts +++ b/frontend/src/i18n/zh.ts @@ -1,6 +1,7 @@ const zh = { nav: { dashboard: '仪表盘', + topology: '拓扑工作台', sources: '数据源', tasks: '任务', records: '采集记录', @@ -44,6 +45,27 @@ const zh = { createdAt: '创建时间', updatedAt: '更新时间', }, + command: { + title: '命令面板', + placeholder: '跳转到记录、任务、节点…', + empty: '没有匹配的操作', + navigation: '导航', + footer: '输入关键词后回车打开', + }, + topology: { + title: '拓扑工作台', + description: '从采集源到任务、智能体、记录和通知的运行关系', + refresh: '刷新', + quickJump: '快速跳转', + allNodes: '全部节点', + running: '运行中', + needsFocus: '需要关注', + ready: '就绪', + refreshing: '刷新中…', + edgeCount: '{{count}} 条连接', + selectNode: '选择一个节点查看上下文', + openDetail: '打开详情', + }, dashboard: { title: '仪表盘', description: '数据采集系统实时概览', @@ -128,6 +150,12 @@ const zh = { noRules: '暂无通知规则', noLogs: '暂无投递日志', ruleId: '规则 ID', + recordId: '记录 ID', + deliveryStatus: '投递状态', + ackStatus: '回执状态', + response: '响应', + ackDetail: '回执详情', + ackedAt: '回执时间', errorMsg: '错误信息', time: '时间', confirmDelete: '确认删除规则 "{{name}}"?', @@ -141,6 +169,8 @@ const zh = { webhookUrl: 'Webhook URL', secret: '签名密钥', webhookSecretHint: '可选,用于 HMAC-SHA256 签名验证(X-Signature-256 请求头)', + ackSecret: '回执密钥', + ackSecretHint: '可选,下游回调 ACK 接口时使用同样算法签名;留空则复用签名密钥', extraHeaders: '自定义请求头', dingtalkUrlHint: '钉钉机器人 Webhook 地址,包含 access_token 参数', dingtalkSecretHint: '可选,开启加签后填写,与机器人加签密钥一致', diff --git a/frontend/src/index.css b/frontend/src/index.css index 1b8eb3c0..bde9dd05 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,6 +2,28 @@ @tailwind components; @tailwind utilities; +@font-face { + font-family: 'Source Han Sans Local'; + src: + local('Source Han Sans CN'), + local('Source Han Sans SC'), + local('Source Han Sans'), + local('Noto Sans SC'), + local('Noto Sans CJK SC'); + font-display: swap; +} + +@font-face { + font-family: 'ToaHI Local'; + src: + local('ToaHI-Rg'), + local('ToaHI Rg'), + local('Toa HI Rg'), + local('ToaHI-Regular'), + local('ToaHI'); + font-display: swap; +} + @keyframes fadeSlideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } @@ -12,56 +34,166 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; + --background: 210 10% 3%; + --foreground: 0 0% 94%; + --card: 210 10% 5%; + --card-foreground: 0 0% 94%; + --popover: 210 10% 5%; + --popover-foreground: 0 0% 94%; + --primary: 3 84% 57%; --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; + --secondary: 210 9% 10%; + --secondary-foreground: 0 0% 88%; + --muted: 210 9% 10%; + --muted-foreground: 220 9% 61%; + --accent: 3 84% 57%; + --accent-foreground: 0 0% 98%; + --destructive: 3 84% 57%; --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 221.2 83.2% 53.3%; - --radius: 0.5rem; + --border: 220 9% 18%; + --input: 220 9% 18%; + --ring: 3 84% 57%; + --radius: 0.125rem; + --font-ui: 'Source Han Sans Local', 'Source Han Sans CN', 'Source Han Sans SC', 'Noto Sans SC', 'Noto Sans CJK SC', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC', 'Hiragino Sans GB', system-ui, sans-serif; + --font-telemetry: 'ToaHI Local', 'ToaHI-Rg', 'ToaHI Rg', 'Source Han Sans Local', 'Source Han Sans CN', 'Noto Sans SC', 'Microsoft YaHei UI', system-ui, sans-serif; + --font-code: 'ToaHI Local', 'ToaHI-Rg', 'ToaHI Rg', 'Cascadia Mono', 'SFMono-Regular', 'JetBrains Mono', ui-monospace, monospace; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 224.3 76.3% 48%; + color-scheme: dark; } * { - @apply border-gray-200; + @apply border-zinc-800; } body { - @apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + @apply min-h-screen bg-[#070809] text-zinc-100 antialiased; + font-family: var(--font-ui); + letter-spacing: 0; + } + + code, + kbd, + pre, + .font-mono { + font-family: var(--font-code); + font-variant-numeric: tabular-nums; + } + + body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 48px 48px; + opacity: 0.55; + } + + #root { + position: relative; + z-index: 1; + min-height: 100vh; + isolation: isolate; + } + + ::selection { + background: rgba(255, 59, 48, 0.35); + color: #fff; + } +} + +@layer components { + .mission-canvas { + background: + linear-gradient(180deg, #0b0d0e 0%, #070809 42%, #050607 100%); + } + + .telemetry-panel { + position: relative; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 2px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)), + rgba(9, 11, 12, 0.9); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + } + + .telemetry-panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px); + background-size: 32px 32px; + opacity: 0.18; + } + + .telemetry-panel > * { + position: relative; + } + + .telemetry-label { + color: rgb(161 161 170); + font-family: var(--font-telemetry); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + } + + .telemetry-value { + color: rgb(250 250 250); + font-family: var(--font-telemetry); + font-variant-numeric: tabular-nums; + letter-spacing: 0; + } + + .telemetry-button { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 2px; + background: rgba(255, 255, 255, 0.035); + color: rgb(212 212 216); + font-family: var(--font-telemetry); + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; + } + + .telemetry-button:hover { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.075); + color: #fff; + } + + .telemetry-button[data-active='true'] { + border-color: rgba(255, 59, 48, 0.75); + background: rgba(255, 59, 48, 0.16); + color: #fff; + } + + .telemetry-input { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 2px; + background: rgba(0, 0, 0, 0.35); + color: #fff; + font-family: var(--font-code); + font-size: 0.75rem; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/frontend/src/lib/collectionWorkflowModel.test.ts b/frontend/src/lib/collectionWorkflowModel.test.ts new file mode 100644 index 00000000..2a3b435e --- /dev/null +++ b/frontend/src/lib/collectionWorkflowModel.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + SOURCE_WORKFLOW_LAYOUT_KEY, + buildCollectionWorkflow, + loadWorkflowLayout, + positionsFromNodes, + saveWorkflowLayout, + workflowNodeId, +} from './collectionWorkflowModel.ts' +import type { CollectionTask, CronSchedule, DataSource } from '../api/types.ts' + +const now = '2026-06-21T08:00:00Z' + +const source: DataSource = { + id: 'source-1', + name: 'OpenCLI Feed', + channel_type: 'opencli', + channel_config: { site: 'x' }, + enabled: true, + tags: ['watch'], + created_at: now, + updated_at: now, +} + +const runningTask: CollectionTask = { + id: 'task-running', + source_id: source.id, + source_name: source.name, + trigger_type: 'manual', + parameters: {}, + priority: 5, + status: 'running', + created_at: '2026-06-21T08:01:00Z', + updated_at: '2026-06-21T08:03:00Z', +} + +const failedTask: CollectionTask = { + ...runningTask, + id: 'task-failed', + status: 'failed', + created_at: '2026-06-21T07:01:00Z', + updated_at: '2026-06-21T07:03:00Z', +} + +const enabledSchedule: CronSchedule = { + id: 'schedule-enabled', + source_id: source.id, + name: 'Morning harvest', + cron_expression: '0 9 * * *', + timezone: 'Asia/Shanghai', + parameters: {}, + enabled: true, + is_one_time: false, + next_run_at: '2026-06-22T01:00:00Z', + created_at: now, + updated_at: now, +} + +const laterSchedule: CronSchedule = { + ...enabledSchedule, + id: 'schedule-later', + name: 'Evening harvest', + next_run_at: '2026-06-22T12:00:00Z', +} + +describe('collection workflow model', () => { + it('aggregates source task, failure, schedule, and next-run metrics', () => { + const graph = buildCollectionWorkflow({ + sources: [source], + tasks: [runningTask, failedTask], + schedules: [laterSchedule, enabledSchedule], + }) + + assert.equal(graph.summary.sources, 1) + assert.equal(graph.summary.tasks, 2) + assert.equal(graph.summary.runningTasks, 1) + assert.equal(graph.summary.failedTasks, 1) + assert.equal(graph.summary.schedules, 2) + assert.equal(graph.summary.enabledSchedules, 2) + + assert.deepEqual(graph.sourceStats[source.id], { + sourceId: source.id, + taskCount: 2, + runningTasks: 1, + failedTasks: 1, + scheduleCount: 2, + enabledScheduleCount: 2, + nextRunAt: enabledSchedule.next_run_at, + latestTaskStatus: runningTask.status, + latestTaskUpdatedAt: runningTask.updated_at, + }) + }) + + it('generates fixed source, schedule, and recent task nodes with plan/run edges', () => { + const graph = buildCollectionWorkflow({ + sources: [source], + tasks: [runningTask], + schedules: [enabledSchedule], + }) + + const nodeIds = graph.nodes.map((node) => node.id) + assert.deepEqual(nodeIds, [ + workflowNodeId('source', source.id), + workflowNodeId('schedule', enabledSchedule.id), + workflowNodeId('task', runningTask.id), + ]) + + const edgeIds = graph.edges.map((edge) => edge.id) + assert.ok(edgeIds.includes(`${workflowNodeId('source', source.id)}->${workflowNodeId('schedule', enabledSchedule.id)}:plans`)) + assert.ok(edgeIds.includes(`${workflowNodeId('source', source.id)}->${workflowNodeId('task', runningTask.id)}:runs`)) + }) + + it('loads, saves, and falls back when layout storage is missing or invalid', () => { + const storage = new MemoryStorage() + + assert.deepEqual(loadWorkflowLayout(storage), {}) + storage.setItem(SOURCE_WORKFLOW_LAYOUT_KEY, '{"source:source-1":{"x":42,"y":64},"bad":{"x":"nope"}}') + assert.deepEqual(loadWorkflowLayout(storage), { + [workflowNodeId('source', source.id)]: { x: 42, y: 64 }, + }) + + storage.setItem(SOURCE_WORKFLOW_LAYOUT_KEY, 'not json') + assert.deepEqual(loadWorkflowLayout(storage), {}) + + const graph = buildCollectionWorkflow({ + sources: [source], + tasks: [runningTask], + schedules: [enabledSchedule], + }, { + layout: { [workflowNodeId('source', source.id)]: { x: 12, y: 24 } }, + }) + assert.equal(graph.nodes[0].position.x, 12) + assert.equal(graph.nodes[1].position.x, 360) + + const positions = positionsFromNodes(graph.nodes) + saveWorkflowLayout(storage, positions) + assert.deepEqual(loadWorkflowLayout(storage), positions) + }) +}) + +class MemoryStorage { + private items = new Map() + + getItem(key: string) { + return this.items.get(key) ?? null + } + + setItem(key: string, value: string) { + this.items.set(key, value) + } +} diff --git a/frontend/src/lib/collectionWorkflowModel.ts b/frontend/src/lib/collectionWorkflowModel.ts new file mode 100644 index 00000000..7f67d76c --- /dev/null +++ b/frontend/src/lib/collectionWorkflowModel.ts @@ -0,0 +1,395 @@ +import type { CollectionTask, CronSchedule, DataSource } from '../api/types' + +export const SOURCE_WORKFLOW_LAYOUT_KEY = 'opencli-admin.sourcesCanvasLayout.v1' + +export type WorkflowNodeKind = 'source' | 'schedule' | 'task' + +export type WorkflowHealth = + | 'healthy' + | 'active' + | 'warning' + | 'failed' + | 'disabled' + | 'unknown' + +export interface WorkflowPosition { + x: number + y: number +} + +export type WorkflowLayoutPositions = Record + +export interface WorkflowNodeData extends Record { + kind: WorkflowNodeKind + title: string + subtitle: string + health: WorkflowHealth + sourceId: string + entityId: string + badges: string[] + detail: Record +} + +export interface WorkflowGraphNode { + id: string + kind: WorkflowNodeKind + sourceId: string + position: WorkflowPosition + data: WorkflowNodeData +} + +export interface WorkflowGraphEdge { + id: string + source: string + target: string + label: string + health: WorkflowHealth +} + +export interface SourceWorkflowStats { + sourceId: string + taskCount: number + runningTasks: number + failedTasks: number + scheduleCount: number + enabledScheduleCount: number + nextRunAt?: string + latestTaskStatus?: CollectionTask['status'] + latestTaskUpdatedAt?: string +} + +export interface CollectionWorkflowGraph { + nodes: WorkflowGraphNode[] + edges: WorkflowGraphEdge[] + sourceStats: Record + summary: { + sources: number + schedules: number + enabledSchedules: number + tasks: number + runningTasks: number + failedTasks: number + } +} + +export interface CollectionWorkflowInput { + sources: DataSource[] + tasks: CollectionTask[] + schedules: CronSchedule[] +} + +export interface CollectionWorkflowOptions { + maxTasksPerSource?: number + layout?: WorkflowLayoutPositions +} + +interface StorageLike { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +const SOURCE_X = 0 +const SCHEDULE_X = 360 +const TASK_X = 720 +const SOURCE_ROW_GAP = 280 +const NODE_ROW_GAP = 118 + +export function buildCollectionWorkflow( + input: CollectionWorkflowInput, + options: CollectionWorkflowOptions = {}, +): CollectionWorkflowGraph { + const maxTasksPerSource = options.maxTasksPerSource ?? 3 + const layout = options.layout ?? {} + const nodes: WorkflowGraphNode[] = [] + const edges: WorkflowGraphEdge[] = [] + const sourceStats: Record = {} + const sourceIds = new Set(input.sources.map((source) => source.id)) + const tasksBySource = groupBy( + input.tasks.filter((task) => sourceIds.has(task.source_id)), + (task) => task.source_id, + ) + const schedulesBySource = groupBy( + input.schedules.filter((schedule) => sourceIds.has(schedule.source_id)), + (schedule) => schedule.source_id, + ) + + input.sources.forEach((source, sourceIndex) => { + const sourceTasks = sortByRecent(tasksBySource.get(source.id) ?? []) + const sourceSchedules = sortSchedules(schedulesBySource.get(source.id) ?? []) + const stats = calculateSourceStats(source.id, sourceTasks, sourceSchedules) + sourceStats[source.id] = stats + + const sourceNodeId = workflowNodeId('source', source.id) + nodes.push({ + id: sourceNodeId, + kind: 'source', + sourceId: source.id, + position: resolvePosition(layout, sourceNodeId, { + x: SOURCE_X, + y: sourceIndex * SOURCE_ROW_GAP, + }), + data: { + kind: 'source', + title: source.name, + subtitle: source.channel_type, + health: healthFromSource(source, stats), + sourceId: source.id, + entityId: source.id, + badges: compact([ + source.enabled ? 'enabled' : 'disabled', + `${stats.taskCount} tasks`, + `${stats.enabledScheduleCount}/${stats.scheduleCount} plans`, + ]), + detail: { + id: source.id, + channel_type: source.channel_type, + description: source.description, + enabled: source.enabled, + tags: source.tags, + updated_at: source.updated_at, + stats, + }, + }, + }) + + sourceSchedules.forEach((schedule, scheduleIndex) => { + const scheduleNodeId = workflowNodeId('schedule', schedule.id) + nodes.push({ + id: scheduleNodeId, + kind: 'schedule', + sourceId: source.id, + position: resolvePosition(layout, scheduleNodeId, { + x: SCHEDULE_X, + y: sourceIndex * SOURCE_ROW_GAP + scheduleIndex * NODE_ROW_GAP, + }), + data: { + kind: 'schedule', + title: schedule.name, + subtitle: schedule.cron_expression, + health: healthFromSchedule(schedule), + sourceId: source.id, + entityId: schedule.id, + badges: compact([ + schedule.enabled ? 'enabled' : 'disabled', + schedule.timezone, + schedule.next_run_at ? `next ${formatShortDate(schedule.next_run_at)}` : undefined, + ]), + detail: { + id: schedule.id, + source_id: schedule.source_id, + agent_id: schedule.agent_id, + cron_expression: schedule.cron_expression, + timezone: schedule.timezone, + enabled: schedule.enabled, + is_one_time: schedule.is_one_time, + next_run_at: schedule.next_run_at, + last_run_at: schedule.last_run_at, + parameters: schedule.parameters, + updated_at: schedule.updated_at, + }, + }, + }) + edges.push({ + id: `${sourceNodeId}->${scheduleNodeId}:plans`, + source: sourceNodeId, + target: scheduleNodeId, + label: 'plans', + health: healthFromSchedule(schedule), + }) + }) + + sourceTasks.slice(0, maxTasksPerSource).forEach((task, taskIndex) => { + const taskNodeId = workflowNodeId('task', task.id) + nodes.push({ + id: taskNodeId, + kind: 'task', + sourceId: source.id, + position: resolvePosition(layout, taskNodeId, { + x: TASK_X, + y: sourceIndex * SOURCE_ROW_GAP + taskIndex * NODE_ROW_GAP, + }), + data: { + kind: 'task', + title: task.source_name || `Task ${shortId(task.id)}`, + subtitle: task.trigger_type, + health: healthFromTaskStatus(task.status), + sourceId: source.id, + entityId: task.id, + badges: compact([task.status, `P${task.priority}`, formatShortDate(task.updated_at)]), + detail: { + id: task.id, + source_id: task.source_id, + agent_id: task.agent_id, + trigger_type: task.trigger_type, + priority: task.priority, + status: task.status, + error_message: task.error_message, + parameters: task.parameters, + created_at: task.created_at, + updated_at: task.updated_at, + }, + }, + }) + edges.push({ + id: `${sourceNodeId}->${taskNodeId}:runs`, + source: sourceNodeId, + target: taskNodeId, + label: 'runs', + health: healthFromTaskStatus(task.status), + }) + }) + }) + + return { + nodes, + edges, + sourceStats, + summary: { + sources: input.sources.length, + schedules: input.schedules.filter((schedule) => sourceIds.has(schedule.source_id)).length, + enabledSchedules: input.schedules.filter((schedule) => sourceIds.has(schedule.source_id) && schedule.enabled).length, + tasks: input.tasks.filter((task) => sourceIds.has(task.source_id)).length, + runningTasks: input.tasks.filter((task) => sourceIds.has(task.source_id) && task.status === 'running').length, + failedTasks: input.tasks.filter((task) => sourceIds.has(task.source_id) && isFailedTask(task)).length, + }, + } +} + +export function loadWorkflowLayout(storage: StorageLike | undefined, key = SOURCE_WORKFLOW_LAYOUT_KEY): WorkflowLayoutPositions { + if (!storage) return {} + try { + const raw = storage.getItem(key) + if (!raw) return {} + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') return {} + const positions: WorkflowLayoutPositions = {} + for (const [nodeId, value] of Object.entries(parsed)) { + if (!value || typeof value !== 'object') continue + const maybePosition = value as Partial + if (typeof maybePosition.x !== 'number' || typeof maybePosition.y !== 'number') continue + positions[nodeId] = { x: maybePosition.x, y: maybePosition.y } + } + return positions + } catch { + return {} + } +} + +export function saveWorkflowLayout( + storage: StorageLike | undefined, + positions: WorkflowLayoutPositions, + key = SOURCE_WORKFLOW_LAYOUT_KEY, +) { + if (!storage) return + storage.setItem(key, JSON.stringify(positions)) +} + +export function positionsFromNodes(nodes: Array<{ id: string; position: WorkflowPosition }>): WorkflowLayoutPositions { + return Object.fromEntries(nodes.map((node) => [node.id, node.position])) +} + +export function workflowNodeId(kind: WorkflowNodeKind, rawId: string) { + return `${kind}:${rawId}` +} + +function calculateSourceStats( + sourceId: string, + tasks: CollectionTask[], + schedules: CronSchedule[], +): SourceWorkflowStats { + const latestTask = sortByRecent(tasks)[0] + const nextSchedule = schedules + .filter((schedule) => schedule.enabled && schedule.next_run_at) + .sort((a, b) => String(a.next_run_at).localeCompare(String(b.next_run_at)))[0] + + return { + sourceId, + taskCount: tasks.length, + runningTasks: tasks.filter((task) => task.status === 'running').length, + failedTasks: tasks.filter(isFailedTask).length, + scheduleCount: schedules.length, + enabledScheduleCount: schedules.filter((schedule) => schedule.enabled).length, + nextRunAt: nextSchedule?.next_run_at, + latestTaskStatus: latestTask?.status, + latestTaskUpdatedAt: latestTask?.updated_at ?? latestTask?.created_at, + } +} + +function healthFromSource(source: DataSource, stats: SourceWorkflowStats): WorkflowHealth { + if (!source.enabled) return 'disabled' + if (stats.failedTasks > 0) return 'failed' + if (stats.runningTasks > 0) return 'active' + if (stats.scheduleCount === 0 || stats.enabledScheduleCount === 0) return 'warning' + return 'healthy' +} + +function healthFromSchedule(schedule: CronSchedule): WorkflowHealth { + if (!schedule.enabled) return 'disabled' + if (!schedule.next_run_at && !schedule.is_one_time) return 'warning' + return 'healthy' +} + +function healthFromTaskStatus(status: CollectionTask['status']): WorkflowHealth { + if (status === 'failed' || status === 'cancelled') return 'failed' + if (status === 'running') return 'active' + if (status === 'pending') return 'warning' + return 'healthy' +} + +function resolvePosition( + layout: WorkflowLayoutPositions, + id: string, + fallback: WorkflowPosition, +): WorkflowPosition { + return layout[id] ?? fallback +} + +function sortByRecent(tasks: CollectionTask[]) { + return [...tasks].sort((a, b) => taskTime(b).localeCompare(taskTime(a))) +} + +function sortSchedules(schedules: CronSchedule[]) { + return [...schedules].sort((a, b) => { + const aEnabled = a.enabled ? 0 : 1 + const bEnabled = b.enabled ? 0 : 1 + if (aEnabled !== bEnabled) return aEnabled - bEnabled + return String(a.next_run_at ?? a.created_at).localeCompare(String(b.next_run_at ?? b.created_at)) + }) +} + +function taskTime(task: CollectionTask) { + return task.updated_at || task.created_at +} + +function isFailedTask(task: CollectionTask) { + return task.status === 'failed' || task.status === 'cancelled' +} + +function shortId(value?: string | null, length = 8) { + return value ? value.slice(0, length) : '' +} + +function formatShortDate(value?: string | null) { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +function groupBy(items: T[], keyFn: (item: T) => string) { + const groups = new Map() + for (const item of items) { + const key = keyFn(item) + groups.set(key, [...(groups.get(key) ?? []), item]) + } + return groups +} + +function compact(items: Array) { + return items.filter((item): item is string => typeof item === 'string' && item.length > 0) +} diff --git a/frontend/src/lib/notificationDisplay.test.ts b/frontend/src/lib/notificationDisplay.test.ts new file mode 100644 index 00000000..3b6b1a31 --- /dev/null +++ b/frontend/src/lib/notificationDisplay.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + formatJsonPreview, + getAckStatusTone, + summarizeNotificationResponse, +} from './notificationDisplay.ts' + +describe('notification display helpers', () => { + it('summarizes HTTP response data with status and body preview', () => { + assert.equal( + summarizeNotificationResponse({ status_code: 202, body: 'queued for delivery' }), + 'HTTP 202 · queued for delivery', + ) + }) + + it('returns an em dash when response data is missing', () => { + assert.equal(summarizeNotificationResponse(null), '—') + }) + + it('formats ack data as readable JSON', () => { + assert.equal( + formatJsonPreview({ downstream_id: 'msg-1', accepted: true }), + '{\n "downstream_id": "msg-1",\n "accepted": true\n}', + ) + }) + + it('maps ack statuses to table tones', () => { + assert.equal(getAckStatusTone('acked'), 'success') + assert.equal(getAckStatusTone('pending'), 'warning') + assert.equal(getAckStatusTone('failed'), 'danger') + assert.equal(getAckStatusTone('not_required'), 'muted') + }) +}) diff --git a/frontend/src/lib/notificationDisplay.ts b/frontend/src/lib/notificationDisplay.ts new file mode 100644 index 00000000..3ad3f9ec --- /dev/null +++ b/frontend/src/lib/notificationDisplay.ts @@ -0,0 +1,43 @@ +export type AckStatusTone = 'success' | 'warning' | 'danger' | 'muted' + +const MAX_PREVIEW_LENGTH = 120 + +function preview(value: unknown): string { + if (value == null || value === '') return '—' + const text = typeof value === 'string' ? value : JSON.stringify(value) + return text.length > MAX_PREVIEW_LENGTH + ? `${text.slice(0, MAX_PREVIEW_LENGTH - 1)}…` + : text +} + +export function summarizeNotificationResponse( + responseData?: Record | null, +): string { + if (!responseData) return '—' + + const statusCode = responseData.status_code + const body = preview(responseData.body) + if (typeof statusCode === 'number' || typeof statusCode === 'string') { + return body === '—' ? `HTTP ${statusCode}` : `HTTP ${statusCode} · ${body}` + } + + return preview(responseData) +} + +export function formatJsonPreview(data?: Record | null): string { + if (!data || Object.keys(data).length === 0) return '—' + return JSON.stringify(data, null, 2) +} + +export function getAckStatusTone(status?: string | null): AckStatusTone { + switch (status) { + case 'acked': + return 'success' + case 'pending': + return 'warning' + case 'failed': + return 'danger' + default: + return 'muted' + } +} diff --git a/frontend/src/lib/topologyModel.test.ts b/frontend/src/lib/topologyModel.test.ts new file mode 100644 index 00000000..07800b04 --- /dev/null +++ b/frontend/src/lib/topologyModel.test.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { buildTopologyGraph, fallbackLayout, nodeId } from './topologyModel.ts' +import type { + AIAgent, + CollectedRecord, + CollectionTask, + CronSchedule, + DataSource, + EdgeNode, + NotificationLog, + NotificationRule, + WorkerNode, +} from '../api/types.ts' + +const now = '2026-06-21T08:00:00Z' + +const source: DataSource = { + id: 'source-1', + name: 'OpenCLI Feed', + channel_type: 'rss', + channel_config: {}, + enabled: true, + tags: ['watch'], + created_at: now, + updated_at: now, +} + +const task: CollectionTask = { + id: 'task-1', + source_id: source.id, + source_name: source.name, + agent_id: 'agent-1', + trigger_type: 'manual', + parameters: {}, + priority: 5, + status: 'running', + created_at: now, + updated_at: now, +} + +const schedule: CronSchedule = { + id: 'schedule-1', + source_id: source.id, + name: 'Morning harvest', + cron_expression: '0 9 * * *', + timezone: 'Asia/Shanghai', + parameters: {}, + enabled: true, + is_one_time: false, + next_run_at: '2026-06-22T01:00:00Z', + created_at: now, + updated_at: now, +} + +const agent: AIAgent = { + id: 'agent-1', + name: 'Summarizer', + processor_type: 'openai', + model: 'gpt-test', + prompt_template: '{{content}}', + processor_config: {}, + enabled: true, + created_at: now, + updated_at: now, +} + +const record: CollectedRecord = { + id: 'record-1', + task_id: task.id, + source_id: source.id, + raw_data: {}, + normalized_data: { title: 'Node-based console' }, + content_hash: 'abcdef123456', + status: 'ai_processed', + created_at: now, + updated_at: now, +} + +const rule: NotificationRule = { + id: 'rule-1', + name: 'Webhook ACK', + source_id: source.id, + trigger_event: 'on_new_record', + notifier_type: 'webhook', + notifier_config: {}, + enabled: true, + created_at: now, + updated_at: now, +} + +const log: NotificationLog = { + id: 'log-1', + rule_id: rule.id, + record_id: record.id, + status: 'sent', + ack_status: 'pending', + created_at: now, +} + +const edgeNode: EdgeNode = { + id: 'edge-1', + url: 'http://node.local:19823', + label: 'NAS node', + protocol: 'http', + mode: 'bridge', + node_type: 'docker', + status: 'online', + created_at: now, + updated_at: now, +} + +const worker: WorkerNode = { + id: 'worker-1', + worker_id: 'celery@nas', + hostname: 'nas', + status: 'online', + active_tasks: 1, + last_heartbeat: now, + created_at: now, + updated_at: now, +} + +describe('topology model', () => { + it('builds data-flow edges across source, task, agent, record, and notification nodes', () => { + const graph = buildTopologyGraph({ + sources: [source], + schedules: [schedule], + tasks: [task], + agents: [agent], + records: [record], + notificationRules: [rule], + notificationLogs: [log], + edgeNodes: [edgeNode], + workers: [worker], + }) + + assert.equal(graph.summary.total, 8) + assert.equal(graph.summary.active, 2) + assert.equal(graph.summary.warning, 1) + + const edgeIds = graph.edges.map((edge) => edge.id) + assert.ok(edgeIds.includes(`${nodeId('source', source.id)}->${nodeId('schedule', schedule.id)}:plans`)) + assert.ok(edgeIds.includes(`${nodeId('source', source.id)}->${nodeId('task', task.id)}:triggers`)) + assert.ok(edgeIds.includes(`${nodeId('task', task.id)}->${nodeId('agent', agent.id)}:enriches`)) + assert.ok(edgeIds.includes(`${nodeId('task', task.id)}->${nodeId('record', record.id)}:writes`)) + assert.ok(edgeIds.includes(`${nodeId('record', record.id)}->${nodeId('notification', rule.id)}:sent`)) + }) + + it('marks disabled and failed states in the graph summary', () => { + const graph = buildTopologyGraph({ + sources: [{ ...source, enabled: false }], + schedules: [{ ...schedule, enabled: false }], + tasks: [{ ...task, status: 'failed' }], + agents: [{ ...agent, enabled: false }], + records: [{ ...record, status: 'error' }], + notificationRules: [{ ...rule, enabled: false }], + notificationLogs: [{ ...log, status: 'failed', ack_status: 'failed' }], + edgeNodes: [{ ...edgeNode, status: 'offline' }], + workers: [{ ...worker, status: 'offline', active_tasks: 0 }], + }) + + assert.equal(graph.summary.failed, 4) + assert.equal(graph.summary.disabled, 4) + }) + + it('provides a deterministic fallback layout', () => { + const graph = buildTopologyGraph({ + sources: [source], + schedules: [], + tasks: [task], + agents: [], + records: [], + notificationRules: [], + notificationLogs: [], + edgeNodes: [], + workers: [], + }) + + const layout = fallbackLayout(graph, 100, 50) + + assert.deepEqual(layout.map((node) => node.position), [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + ]) + }) +}) diff --git a/frontend/src/lib/topologyModel.ts b/frontend/src/lib/topologyModel.ts new file mode 100644 index 00000000..cdc55204 --- /dev/null +++ b/frontend/src/lib/topologyModel.ts @@ -0,0 +1,419 @@ +import type { + AIAgent, + CollectedRecord, + CollectionTask, + CronSchedule, + DataSource, + EdgeNode, + NotificationLog, + NotificationRule, + WorkerNode, +} from '../api/types' + +export type TopologyKind = + | 'source' + | 'schedule' + | 'task' + | 'agent' + | 'record' + | 'notification' + | 'edge-node' + | 'worker' + +export type TopologyHealth = + | 'healthy' + | 'active' + | 'warning' + | 'failed' + | 'disabled' + | 'unknown' + +export interface TopologyNodeData extends Record { + kind: TopologyKind + title: string + subtitle: string + health: TopologyHealth + badges: string[] + targetPath?: string + detail: Record +} + +interface TopologyNodeBody { + title: string + subtitle: string + health: TopologyHealth + badges: string[] + targetPath?: string + detail: Record +} + +export interface TopologyGraphNode { + id: string + column: number + row: number + data: TopologyNodeData +} + +export interface TopologyGraphEdge { + id: string + source: string + target: string + label?: string + health: TopologyHealth +} + +export interface TopologyGraph { + nodes: TopologyGraphNode[] + edges: TopologyGraphEdge[] + summary: { + total: number + failed: number + warning: number + active: number + disabled: number + } +} + +export interface TopologyInput { + sources: DataSource[] + schedules?: CronSchedule[] + tasks: CollectionTask[] + agents: AIAgent[] + records: CollectedRecord[] + notificationRules: NotificationRule[] + notificationLogs: NotificationLog[] + edgeNodes: EdgeNode[] + workers: WorkerNode[] +} + +export interface TopologyOptions { + maxRecords?: number + maxNotifications?: number +} + +const KIND_COLUMN: Record = { + source: 0, + schedule: 1, + task: 2, + agent: 3, + record: 4, + notification: 5, + 'edge-node': 2, + worker: 3, +} + +export function buildTopologyGraph(input: TopologyInput, options: TopologyOptions = {}): TopologyGraph { + const maxRecords = options.maxRecords ?? 18 + const maxNotifications = options.maxNotifications ?? 20 + const nodes: TopologyGraphNode[] = [] + const edges: TopologyGraphEdge[] = [] + const seenNodes = new Set() + const seenEdges = new Set() + const rowsByColumn = new Map() + + const addNode = (kind: TopologyKind, rawId: string, data: TopologyNodeBody) => { + const id = nodeId(kind, rawId) + if (seenNodes.has(id)) return id + + const column = KIND_COLUMN[kind] + const row = rowsByColumn.get(column) ?? 0 + rowsByColumn.set(column, row + 1) + nodes.push({ id, column, row, data: { ...data, kind } }) + seenNodes.add(id) + return id + } + + const addEdge = (source: string | undefined, target: string | undefined, label?: string, health: TopologyHealth = 'unknown') => { + if (!source || !target || source === target) return + if (!seenNodes.has(source) || !seenNodes.has(target)) return + const id = `${source}->${target}${label ? `:${label}` : ''}` + if (seenEdges.has(id)) return + edges.push({ id, source, target, label, health }) + seenEdges.add(id) + } + + for (const source of input.sources) { + addNode('source', source.id, { + title: source.name, + subtitle: source.channel_type, + health: source.enabled ? 'healthy' : 'disabled', + badges: compact([source.enabled ? 'enabled' : 'disabled', ...source.tags.slice(0, 2)]), + targetPath: '/sources', + detail: { + id: source.id, + channel: source.channel_type, + updated_at: source.updated_at, + }, + }) + } + + for (const schedule of input.schedules ?? []) { + const scheduleNode = addNode('schedule', schedule.id, { + title: schedule.name, + subtitle: schedule.cron_expression, + health: healthFromSchedule(schedule), + badges: compact([ + schedule.enabled ? 'enabled' : 'disabled', + schedule.timezone, + schedule.next_run_at ? `next ${shortDate(schedule.next_run_at)}` : undefined, + ]), + targetPath: `/schedules?source_id=${encodeURIComponent(schedule.source_id)}`, + detail: { + id: schedule.id, + source_id: schedule.source_id, + agent_id: schedule.agent_id, + cron_expression: schedule.cron_expression, + timezone: schedule.timezone, + enabled: schedule.enabled, + is_one_time: schedule.is_one_time, + next_run_at: schedule.next_run_at, + last_run_at: schedule.last_run_at, + updated_at: schedule.updated_at, + }, + }) + addEdge(nodeId('source', schedule.source_id), scheduleNode, 'plans', healthFromSchedule(schedule)) + } + + for (const task of input.tasks) { + const taskNode = addNode('task', task.id, { + title: task.source_name || `Task ${shortId(task.id)}`, + subtitle: task.trigger_type, + health: healthFromTaskStatus(task.status), + badges: compact([task.status, `P${task.priority}`]), + targetPath: '/tasks', + detail: { + id: task.id, + source_id: task.source_id, + agent_id: task.agent_id, + status: task.status, + updated_at: task.updated_at, + error: task.error_message, + }, + }) + addEdge(nodeId('source', task.source_id), taskNode, 'triggers', healthFromTaskStatus(task.status)) + } + + for (const agent of input.agents) { + addNode('agent', agent.id, { + title: agent.name, + subtitle: agent.model || agent.processor_type, + health: agent.enabled ? 'healthy' : 'disabled', + badges: compact([agent.enabled ? 'enabled' : 'disabled', agent.processor_type]), + targetPath: '/agents', + detail: { + id: agent.id, + processor_type: agent.processor_type, + provider_id: agent.provider_id, + updated_at: agent.updated_at, + }, + }) + } + + for (const task of input.tasks) { + addEdge( + nodeId('task', task.id), + task.agent_id ? nodeId('agent', task.agent_id) : undefined, + 'enriches', + healthFromTaskStatus(task.status), + ) + } + + const sampledRecords = [...input.records] + .sort((a, b) => b.created_at.localeCompare(a.created_at)) + .slice(0, maxRecords) + + for (const record of sampledRecords) { + const title = readRecordTitle(record) || `Record ${shortId(record.id)}` + const recordNode = addNode('record', record.id, { + title, + subtitle: record.status, + health: healthFromRecordStatus(record.status), + badges: compact([record.status, shortId(record.content_hash)]), + targetPath: '/records', + detail: { + id: record.id, + source_id: record.source_id, + task_id: record.task_id, + status: record.status, + created_at: record.created_at, + error: record.error_message, + }, + }) + addEdge(nodeId('task', record.task_id), recordNode, 'writes', healthFromRecordStatus(record.status)) + addEdge(nodeId('source', record.source_id), recordNode, 'collects', healthFromRecordStatus(record.status)) + } + + const logsByRule = groupBy(input.notificationLogs, (log) => log.rule_id) + const logsByRecord = groupBy(input.notificationLogs, (log) => log.record_id || '') + + for (const rule of input.notificationRules.slice(0, maxNotifications)) { + const ruleLogs = logsByRule.get(rule.id) ?? [] + const notificationNode = addNode('notification', rule.id, { + title: rule.name, + subtitle: rule.notifier_type, + health: healthFromNotification(rule, ruleLogs), + badges: compact([rule.enabled ? 'enabled' : 'disabled', rule.trigger_event]), + targetPath: '/notifications', + detail: { + id: rule.id, + source_id: rule.source_id, + trigger_event: rule.trigger_event, + notifier_type: rule.notifier_type, + recent_logs: ruleLogs.length, + }, + }) + addEdge(rule.source_id ? nodeId('source', rule.source_id) : undefined, notificationNode, 'notifies', healthFromNotification(rule, ruleLogs)) + } + + for (const record of sampledRecords) { + const recordLogs = logsByRecord.get(record.id) ?? [] + for (const log of recordLogs) { + addEdge( + nodeId('record', record.id), + nodeId('notification', log.rule_id), + log.ack_status === 'acked' ? 'acked' : 'sent', + healthFromNotificationLog(log), + ) + } + } + + for (const node of input.edgeNodes) { + addNode('edge-node', node.id, { + title: node.label || node.url, + subtitle: `${node.protocol.toUpperCase()} · ${node.mode}`, + health: node.status === 'online' ? 'healthy' : 'failed', + badges: compact([node.node_type, node.status]), + targetPath: '/nodes', + detail: { + id: node.id, + url: node.url, + ip: node.ip, + last_seen_at: node.last_seen_at, + }, + }) + } + + for (const worker of input.workers) { + addNode('worker', worker.id, { + title: worker.hostname || worker.worker_id, + subtitle: worker.worker_id, + health: healthFromWorker(worker), + badges: compact([worker.status, `${worker.active_tasks} active`]), + targetPath: '/workers', + detail: { + id: worker.id, + worker_id: worker.worker_id, + active_tasks: worker.active_tasks, + last_heartbeat: worker.last_heartbeat, + }, + }) + } + + const summary = nodes.reduce( + (acc, node) => ({ + total: acc.total + 1, + failed: acc.failed + (node.data.health === 'failed' ? 1 : 0), + warning: acc.warning + (node.data.health === 'warning' ? 1 : 0), + active: acc.active + (node.data.health === 'active' ? 1 : 0), + disabled: acc.disabled + (node.data.health === 'disabled' ? 1 : 0), + }), + { total: 0, failed: 0, warning: 0, active: 0, disabled: 0 }, + ) + + return { nodes, edges, summary } +} + +export function fallbackLayout(graph: TopologyGraph, columnGap = 280, rowGap = 136) { + return graph.nodes.map((node) => ({ + ...node, + position: { + x: node.column * columnGap, + y: node.row * rowGap, + }, + })) +} + +export function nodeId(kind: TopologyKind, rawId: string) { + return `${kind}:${rawId}` +} + +export function shortId(value?: string | null, length = 8) { + return value ? value.slice(0, length) : '' +} + +function healthFromTaskStatus(status: CollectionTask['status']): TopologyHealth { + if (status === 'failed' || status === 'cancelled') return 'failed' + if (status === 'running') return 'active' + if (status === 'pending') return 'warning' + return 'healthy' +} + +function healthFromSchedule(schedule: CronSchedule): TopologyHealth { + if (!schedule.enabled) return 'disabled' + if (!schedule.next_run_at && !schedule.is_one_time) return 'warning' + return 'healthy' +} + +function healthFromRecordStatus(status: string): TopologyHealth { + if (status === 'error' || status === 'failed') return 'failed' + if (status === 'raw' || status === 'normalized') return 'warning' + if (status === 'ai_processed' || status === 'stored') return 'healthy' + return 'unknown' +} + +function healthFromNotification(rule: NotificationRule, logs: NotificationLog[]): TopologyHealth { + if (!rule.enabled) return 'disabled' + if (logs.some((log) => log.status === 'failed' || log.ack_status === 'failed')) return 'failed' + if (logs.some((log) => log.ack_status === 'pending')) return 'warning' + return 'healthy' +} + +function healthFromNotificationLog(log: NotificationLog): TopologyHealth { + if (log.status === 'failed' || log.ack_status === 'failed') return 'failed' + if (log.ack_status === 'pending') return 'warning' + if (log.ack_status === 'acked') return 'healthy' + return 'unknown' +} + +function healthFromWorker(worker: WorkerNode): TopologyHealth { + const status = worker.status.toLowerCase() + if (status.includes('offline') || status.includes('failed') || status.includes('error')) return 'failed' + if (worker.active_tasks > 0 || status.includes('busy') || status.includes('active')) return 'active' + if (status.includes('starting') || status.includes('pending')) return 'warning' + return 'healthy' +} + +function readRecordTitle(record: CollectedRecord) { + const candidates = [ + record.normalized_data.title, + record.raw_data.title, + record.normalized_data.url, + record.raw_data.url, + ] + return candidates.find((value): value is string => typeof value === 'string' && value.trim().length > 0)?.trim() +} + +function shortDate(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +function groupBy(items: T[], keyFn: (item: T) => string) { + const groups = new Map() + for (const item of items) { + const key = keyFn(item) + if (!key) continue + groups.set(key, [...(groups.get(key) ?? []), item]) + } + return groups +} + +function compact(items: Array) { + return items.filter((item): item is string => typeof item === 'string' && item.length > 0) +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d32b0fe6..0ee502b1 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { type ClassValue, clsx } from 'clsx' -import { twMerge } from 'tailwind-merge' - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bc418c87..978a4353 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,25 +1,25 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { Toaster } from 'sonner' -import App from './App' -import './index.css' -import './i18n' - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: 1, - }, - }, -}) - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - -) +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Toaster } from 'sonner' +import App from './App' +import './index.css' +import './i18n' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + +) diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index ea34f60c..19b4f382 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -1,764 +1,764 @@ -import { useRef, useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -import { listAgents, createAgent, updateAgent, deleteAgent, listProviders } from '../api/endpoints' -import type { AIAgent, ModelProvider } from '../api/types' -import { PageLoader } from '../components/LoadingSpinner' -import ErrorAlert from '../components/ErrorAlert' -import Card from '../components/Card' -import DataTable from '../components/DataTable' -import PageHeader from '../components/PageHeader' -import { COMMANDS_BY_SITE, SITE_EXTRA_FIELDS, SITE_LABELS, SITE_STANDARD_FIELDS } from '../components/ChannelConfigForm' -import { Plus, Pencil, Trash2, ToggleLeft, ToggleRight } from 'lucide-react' - -const PROCESSOR_COLORS: Record = { - claude: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', - openai: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - local: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', -} - -function ProcessorBadge({ type, processorType }: { type: string; processorType?: string }) { - const cls = PROCESSOR_COLORS[processorType ?? type] ?? 'bg-gray-100 text-gray-700' - return ( - - {type} - - ) -} - -const inputCls = - 'w-full border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500' -const labelCls = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' - -// Provider definitions: maps to processor_type + pre-filled config -type Provider = { - key: string - label: string - processor_type: 'claude' | 'openai' | 'local' - base_url?: string - default_model: string - needs_api_key: boolean - base_url_editable?: boolean -} - -const PROVIDERS: Provider[] = [ - { key: 'claude', label: 'Claude (Anthropic)', processor_type: 'claude', default_model: 'claude-haiku-4-5-20251001', needs_api_key: true }, - { key: 'openai', label: 'OpenAI', processor_type: 'openai', default_model: 'gpt-4o-mini', needs_api_key: true }, - { key: 'deepseek', label: 'DeepSeek', processor_type: 'openai', base_url: 'https://api.deepseek.com/v1', default_model: 'deepseek-chat', needs_api_key: true }, - { key: 'kimi', label: 'Kimi (Moonshot)', processor_type: 'openai', base_url: 'https://api.moonshot.cn/v1', default_model: 'moonshot-v1-8k', needs_api_key: true }, - { key: 'glm', label: 'GLM (智谱)', processor_type: 'openai', base_url: 'https://open.bigmodel.cn/api/paas/v4/', default_model: 'glm-4-flash', needs_api_key: true }, - { key: 'minimax', label: 'MiniMax', processor_type: 'openai', base_url: 'https://api.minimax.chat/v1', default_model: 'abab6.5s-chat', needs_api_key: true }, - { key: 'ollama', label: 'Ollama(本地)', processor_type: 'local', base_url: 'http://localhost:11434', default_model: 'llama3', needs_api_key: false, base_url_editable: true }, - { key: 'custom', label: '自定义', processor_type: 'openai', base_url: '', default_model: '', needs_api_key: true, base_url_editable: true }, -] - -const PROVIDER_MAP = Object.fromEntries(PROVIDERS.map((p) => [p.key, p])) - -// Standard fields always available after normalization -const STANDARD_FIELDS = ['title', 'url', 'content', 'author', 'published_at', 'source_id'] - -// Chinese descriptions for all known fields (standard + site-specific extra_*) -const FIELD_LABELS: Record = { - // Standard - source_id: '数据源 ID', - title: '标题', - url: '链接', - content: '正文', - author: '作者', - published_at: '发布时间', - // Common extra - rank: '排名', - id: '条目 ID', - likes: '点赞数', - score: '评分', - comments: '评论数', - plays: '播放量', - play: '播放量', - views: '浏览量', - // Bilibili - danmaku: '弹幕数', - // Zhihu - heat: '热度', - answers: '回答数', - votes: '投票数', - // Weibo - hot_value: '热度值', - category: '分类', - label: '标签', - // V2EX / HN / Reddit - subreddit: '子版块', - upvotes: '赞数', - section: '栏目', - // Xueqiu / Finance - symbol: '股票代码', - price: '价格', - change: '涨跌额', - changePercent: '涨跌幅', - changePct: '涨跌幅', - open: '开盘价', - high: '最高价', - low: '最低价', - volume: '成交量', - marketCap: '市值', - peRatio: '市盈率', - eps: '每股收益', - heat_value: '热度值', - // SMZDM - mall: '商城', - // Boss - salary: '薪资', - company: '公司', - area: '地区', - experience: '工作经验', - degree: '学历要求', - skills: '技能', - boss: 'HR', - // Ctrip - type: '类型', - // Xiaoyuzhou - subscribers: '订阅数', - episodes: '期数', - eid: '单集 ID', - duration: '时长', - // Twitter - tweets: '推文数', - retweets: '转发数', - replies: '回复数', - // LinkedIn - location: '地点', - // Youtube - // (views and duration already above) -} - -// Preset prompt templates -const PROMPT_PRESETS = [ - { - key: 'summary', - label: '内容摘要', - template: '请对以下内容生成一段简洁的中文摘要(150字以内):\n\n标题:{{title}}\n作者:{{author}}\n来源:{{source_id}}\n\n正文:\n{{content}}\n\n链接:{{url}}', - }, - { - key: 'tags', - label: '关键标签', - template: '请从以下内容中提取 3-5 个关键标签,用中文逗号分隔,只输出标签,不要其他内容:\n\n标题:{{title}}\n内容:{{content}}', - }, - { - key: 'sentiment', - label: '情感分析', - template: '请分析以下内容的情感倾向,按如下格式输出:\n情感:正面/中性/负面\n理由:(一句话解释)\n\n标题:{{title}}\n内容:{{content}}', - }, - { - key: 'trending', - label: '热榜解读', - template: '以下是一条热榜内容,请简要说明其热度原因和潜在影响(100字以内):\n\n标题:{{title}}\n热度排名:{{extra_rank}}\n来源:{{source_id}}\n链接:{{url}}', - }, - { - key: 'structured', - label: '结构化提取', - template: '请从以下内容中提取关键信息,以 JSON 格式输出,包含字段:summary(摘要)、keywords(关键词数组)、entities(实体数组):\n\n标题:{{title}}\n内容:{{content}}\n链接:{{url}}', - }, -] - -// Ordered site groups for the extra-field picker -const SITE_GROUPS = [ - { label: '🇨🇳 国内', sites: ['xiaohongshu', 'bilibili', 'zhihu', 'weibo', 'v2ex', 'xueqiu', 'smzdm', 'boss', 'ctrip', 'xiaoyuzhou'] }, - { label: '🌐 Public', sites: ['hackernews', 'bbc', 'reuters'] }, - { label: '🌍 Global', sites: ['twitter', 'reddit', 'youtube', 'linkedin', 'yahoo-finance', 'barchart'] }, -] - -function AgentModal({ - initial, - onClose, - onSave, -}: { - initial?: AIAgent - onClose: () => void - onSave: (data: Partial) => void -}) { - const { t } = useTranslation() - const isEdit = !!initial - const textareaRef = useRef(null) - - const { data: providersData } = useQuery({ - queryKey: ['providers'], - queryFn: listProviders, - }) - const savedProviders = (providersData?.data ?? []).filter((p) => p.enabled) - - // Derive initial provider key from existing agent data - const deriveProviderKey = (): string => { - if (!initial) return 'claude' - const pt = initial.processor_type - const bu = (initial.processor_config as Record)?.base_url as string | undefined - if (pt === 'claude') return 'claude' - if (pt === 'local') return 'ollama' - if (!bu) return 'openai' - const match = PROVIDERS.find((p) => p.processor_type === 'openai' && p.base_url && bu.startsWith(p.base_url.replace(/\/$/, '').split('/v')[0])) - return match?.key ?? 'custom' - } - - const [name, setName] = useState(initial?.name ?? '') - const [description, setDescription] = useState(initial?.description ?? '') - // useSavedProvider: true = pick from saved providers; false = configure inline - const [useSavedProvider, setUseSavedProvider] = useState(!!initial?.provider_id) - const [savedProviderId, setSavedProviderId] = useState(initial?.provider_id ?? '') - const [providerKey, setProviderKey] = useState(deriveProviderKey) - const [model, setModel] = useState(initial?.model ?? PROVIDER_MAP['claude'].default_model) - const [apiKey, setApiKey] = useState((initial?.processor_config as Record)?.api_key as string ?? '') - const [baseUrl, setBaseUrl] = useState((initial?.processor_config as Record)?.base_url as string ?? '') - const [promptTemplate, setPromptTemplate] = useState( - initial?.prompt_template ?? PROMPT_PRESETS[0].template - ) - const [selectedSite, setSelectedSite] = useState('') - const [selectedCommand, setSelectedCommand] = useState('') - - const provider = PROVIDER_MAP[providerKey] ?? PROVIDERS[0] - const selectedSavedProvider: ModelProvider | undefined = savedProviders.find((p) => p.id === savedProviderId) - - const handleProviderChange = (key: string) => { - const p = PROVIDER_MAP[key] - if (!p) return - setProviderKey(key) - setBaseUrl(p.base_url ?? '') - if (!isEdit) setModel(p.default_model) - } - - const handleSavedProviderChange = (id: string) => { - setSavedProviderId(id) - const p = savedProviders.find((sp) => sp.id === id) - if (p) { - if (p.default_model && !isEdit) setModel(p.default_model) - } - } - - const insertPlaceholder = (ph: string) => { - const el = textareaRef.current - if (!el) { - setPromptTemplate((prev) => prev + ph) - return - } - const start = el.selectionStart - const end = el.selectionEnd - const next = promptTemplate.slice(0, start) + ph + promptTemplate.slice(end) - setPromptTemplate(next) - requestAnimationFrame(() => { - el.selectionStart = el.selectionEnd = start + ph.length - el.focus() - }) - } - - const siteCommands = selectedSite ? (COMMANDS_BY_SITE[selectedSite] ?? []) : [] - const siteKey = selectedSite && selectedCommand ? `${selectedSite}:${selectedCommand}` : '' - const siteStandardFields: string[] | null = siteKey ? (SITE_STANDARD_FIELDS[siteKey] ?? null) : null - const extraFields = siteKey ? (SITE_EXTRA_FIELDS[siteKey] ?? []) : [] - - const handleSiteChange = (site: string) => { - setSelectedSite(site) - const cmds = COMMANDS_BY_SITE[site] ?? [] - const firstCmd = cmds[0]?.command ?? '' - setSelectedCommand(firstCmd) - } - - const handleSubmit = () => { - const processorConfig: Record = {} - if (!useSavedProvider) { - if (apiKey) processorConfig.api_key = apiKey - if (baseUrl) processorConfig.base_url = baseUrl - } - const processorType = useSavedProvider - ? (selectedSavedProvider?.provider_type ?? 'openai') - : provider.processor_type - onSave({ - name, - description: description || undefined, - processor_type: processorType, - model: model || undefined, - prompt_template: promptTemplate, - processor_config: processorConfig, - enabled: initial?.enabled ?? true, - provider_id: useSavedProvider && savedProviderId ? savedProviderId : undefined, - }) - } - - return ( -
-
-
-

- {isEdit ? t('agents.editTitle') : t('agents.addTitle')} -

-
- -
- {/* Name + description */} -
-
- - setName(e.target.value)} - placeholder="内容摘要助手" - /> -
-
- - setDescription(e.target.value)} - placeholder={t('agents.descriptionPlaceholder')} - /> -
-
- - {/* Provider + model + credentials */} -
-
-

模型配置

- {savedProviders.length > 0 && ( -
- - -
- )} -
- - {useSavedProvider ? ( - /* Saved provider mode */ -
-
- - -
- {selectedSavedProvider && ( -
-
- 类型 - {selectedSavedProvider.provider_type} -
- {selectedSavedProvider.base_url && ( -
- Base URL - {selectedSavedProvider.base_url} -
- )} -
- API Key - {selectedSavedProvider.api_key ? '••••••••' : '未配置(读环境变量)'} -
-
- )} -
- - setModel(e.target.value)} - placeholder={selectedSavedProvider?.default_model ?? ''} - /> -
-
- ) : ( - /* Inline config mode */ - <> -
-
- - -
-
- - setModel(e.target.value)} - placeholder={provider.default_model} - /> -
-
- - {(provider.needs_api_key || provider.base_url_editable) && ( -
- {provider.needs_api_key && ( -
- - setApiKey(e.target.value)} - placeholder="sk-..." - /> -
- )} - {provider.base_url_editable && ( -
- - setBaseUrl(e.target.value)} - placeholder={provider.base_url || 'https://api.example.com/v1'} - /> -
- )} -
- )} - - {!provider.needs_api_key && !provider.base_url_editable && provider.base_url && ( -

- 接入点:{provider.base_url} -

- )} - - )} -
- - {/* Prompt section */} -
-
- -
- - {/* Preset template chips */} -
- 预设: - {PROMPT_PRESETS.map((p) => ( - - ))} -
- -