diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df9c84e..cf88133 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -132,6 +132,12 @@ jobs:
set -Eeuo pipefail
corepack pnpm --dir admin-dashboard typecheck
+ - name: Check dashboard design contract
+ shell: bash
+ run: |
+ set -Eeuo pipefail
+ corepack pnpm --dir admin-dashboard check:design
+
production-build:
name: Production build
runs-on: ubuntu-24.04
@@ -355,7 +361,7 @@ jobs:
dashboard_html="$(curl --fail --silent --show-error --retry 10 --retry-all-errors --retry-delay 2 \
"http://127.0.0.1:${DASHBOARD_PORT}/")"
- grep --fixed-strings --quiet 'WaitQueue Workbench' <<< "$dashboard_html"
+ grep --fixed-strings --quiet 'data-product="waitqueue-console"' <<< "$dashboard_html"
node -e '
let html = "";
process.stdin.setEncoding("utf8");
diff --git a/README.md b/README.md
index f0e15e2..9aa6393 100644
--- a/README.md
+++ b/README.md
@@ -644,10 +644,10 @@ curl -X POST http://127.0.0.1:3000/waitqueue/admin/deadLetters/replay \
控制台位于 `admin-dashboard/`,不是 mock 模板,也不是后端启动依赖。它提供:
- 10 秒自动刷新,页面不可见时暂停轮询;
-- 基于 Ant Design 6 的紧凑工作台、浅/深主题和移动端布局;
+- 基于 Ant Design 6、对齐 fullstack-ai-infra Warm Agent Workspace 的浅/深主题与移动端布局;
- 真实 waiting、running、retrying、DLQ、最老等待与容量利用率;
- 当前进程 callback、claim、recovery 计数及起始时间;
-- 左侧实时队列目录、Workbench 摘要与并发槽位视图;
+- 72px 模块栏、256px 队列上下文目录、60px 顶栏与独立队列详情页;
- 队列搜索、并发占用和三类 cron 展示;
- 注册/更新队列、向指定队列提交任务、分页查询与 generation-safe DLQ 重放;
- 离线、过期、加载和空数据状态。
diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md
index 2497600..d92b368 100644
--- a/admin-dashboard/README.md
+++ b/admin-dashboard/README.md
@@ -1,19 +1,21 @@
-# WaitQueue Workbench
+# WaitQueue 队列运行中心
waitqueue.js 的轻量实时运维控制台。它直接读取后端队列配置、Redis 运行快照和当前进程计数,不使用 mock 数据,也不展示当前系统无法证明的历史趋势。
-界面沿用 DWS Backend 的开发者工作台语言:数据目录、紧凑顶栏、黑色 Workbench 横幅、1px 深色描边和薄荷绿运行状态。交互控件使用 Ant Design 6,布局与视觉 token 保持项目自身风格,没有引入 Ant Design Pro、图表运行时或额外状态库。
+界面遵循 [fullstack-ai-infra/design-system@9d048faa](https://github.com/fullstack-ai-infra/design-system/tree/9d048faaabe0429a6a8720bfbb31418544237b6b) 的 **Warm Agent Workspace** 视觉契约:暖象牙画布、stone 导航、paper 表面、charcoal 文字和 sage 主操作。布局采用 72px 模块栏、256px 队列上下文栏和 60px 顶栏;表格、表单、抽屉、弹窗及反馈继续由 Ant Design 6 提供。
+
+设计系统当前尚未发布稳定 npm 版本,且其 React 18 peer contract 与本项目 React 19 不兼容,因此这里使用轻量 semantic adapter 将上游 token 映射到 Ant Design `ConfigProvider`,不引入重复的 Radix、Tailwind 或状态管理运行时。待设计系统正式支持 React 19 后,可直接切换到包依赖。
## 页面怎么用
-顶部四个工作区分别承担不同职责:
+左侧模块栏的四个页面分别承担不同职责:
-1. **总览**:先看 backlog、最老 waiting、Retry、DLQ、容量利用率,以及本进程 callback/claim/recovery 计数。计数的起始时间显示在卡片底部,服务重启后会归零。
-2. **队列**:在左侧目录搜索 namespace、回调 origin 或 queue ID;点选队列后查看运行槽位、Cron 和真实运行状态,也可提交 taskId 或更新配置。
+1. **总览**:先看等待任务、最老等待、运行容量、Retry、DLQ,以及本进程 callback/claim/recovery 计数。计数的起始时间显示在卡片底部,服务重启后会归零。
+2. **队列**:在上下文目录搜索 namespace、回调 origin 或 queue ID;点选队列后查看运行槽位、Cron 和真实运行状态,也可提交 taskId 或更新配置。
3. **死信**:选择队列后分页查看 DLQ;重放前必须二次确认,后端用 `entryId` 校验 generation,避免把已更新的旧记录误重放。
4. **诊断**:分别查看进程 liveness、MySQL/Redis readiness,并核对 Prometheus 抓取契约。
-页面每 10 秒自动刷新,浏览器切到后台时暂停;手动刷新会同时更新队列快照和健康状态。顶栏的 `ONLINE` 只有在 liveness 与 readiness 都成功时才出现,依赖异常会明确显示为 `DEGRADED`。桌面端使用表格与固定队列目录,窄屏切换为卡片、抽屉目录和底部视图导航;浅色/深色主题共用同一套 Ant Design token 状态。
+页面每 10 秒自动刷新,浏览器切到后台时暂停;手动刷新会同时更新队列快照和健康状态。顶栏只有在 liveness 与 readiness 都成功时才显示“服务在线”,依赖异常会显示“依赖异常”。桌面端使用模块栏、固定队列目录和表格,窄屏隐藏目录并通过抽屉选择队列,表格自动切换为卡片;浅色/深色主题共用同一套语义 token。
当前没有任务历史存储,因此页面不会展示吞吐趋势、成功率、平均耗时或任务明细。
@@ -64,6 +66,7 @@ DASHBOARD_ALLOWED_HOSTS=127.0.0.1,localhost,[::1]
```bash
corepack pnpm --dir admin-dashboard typecheck
+corepack pnpm --dir admin-dashboard check:design
corepack pnpm --dir admin-dashboard build
corepack pnpm --dir admin-dashboard start
```
@@ -101,17 +104,17 @@ Browser
## 技术与目录
-运行时直接依赖只有 Next.js、React、Ant Design、图标与其 SSR 样式运行时;没有 Ant Design Pro、Redux、Axios、Mock.js 或图表库。Pages Router 使用 `_document.tsx` 提取 Ant Design CSS-in-JS 样式,避免首屏闪烁。Next 16 的开发与生产构建显式使用 Webpack,以确保 Ant Design 与提取器共享同一个样式上下文;CI 会检查产物中存在真实 Ant 组件规则,而不只检查空的 style 标签。
+运行时直接依赖只有 Next.js、React、Ant Design、Lucide 图标与 Ant SSR 样式运行时;没有 Ant Design Pro、Redux、Axios、Mock.js、Tailwind、Radix 或图表库。Lucide 与设计系统的图标契约一致,并按组件 tree-shaking。Pages Router 使用 `_document.tsx` 提取 Ant Design CSS-in-JS 样式,避免首屏闪烁。Next 16 的开发与生产构建显式使用 Webpack,以确保 Ant Design 与提取器共享同一个样式上下文;CI 会检查产物中存在真实 Ant 组件规则,而不只检查空的 style 标签。
```text
admin-dashboard/
├── src/pages/_app.tsx # 全局样式与主题入口
├── src/pages/_document.tsx # Ant Design 服务端样式提取
-├── src/pages/index.tsx # 数据读取、交互与控制室页面
+├── src/pages/index.tsx # 数据读取、交互与四个运行页面
├── src/pages/api/waitqueue/ # 运行时白名单代理与 token 注入
-├── src/style/global.css # 设计 token、主题与基础样式
-├── src/style/dashboard.module.css # 工作台布局、状态组件和响应式样式
-├── src/theme/ # Ant Design token 与浅/深主题状态
+├── src/style/global.css # design-system 语义适配与基础样式
+├── src/style/dashboard.module.css # Warm Queue Console 布局与响应式样式
+├── src/theme/ # 语义 token 到 Ant Design 的映射
├── next.config.js # API 同源代理
└── .env.example # 后端地址示例
```
diff --git a/admin-dashboard/package.json b/admin-dashboard/package.json
index e5bfc3f..9fa7f8f 100644
--- a/admin-dashboard/package.json
+++ b/admin-dashboard/package.json
@@ -11,12 +11,14 @@
"dev": "next dev --webpack -p 3001",
"build": "next build --webpack",
"start": "next start -p 3001",
- "typecheck": "next typegen && tsc --noEmit"
+ "typecheck": "next typegen && tsc --noEmit",
+ "check:design": "node scripts/check-design-contract.mjs"
},
"dependencies": {
"@ant-design/cssinjs": "2.1.2",
- "@ant-design/icons": "6.3.2",
+ "@fontsource/inter": "5.3.0",
"antd": "6.6.0",
+ "lucide-react": "0.468.0",
"next": "16.3.0",
"react": "19.2.0",
"react-dom": "19.2.0"
diff --git a/admin-dashboard/pnpm-lock.yaml b/admin-dashboard/pnpm-lock.yaml
index 8ca10a6..f59a723 100644
--- a/admin-dashboard/pnpm-lock.yaml
+++ b/admin-dashboard/pnpm-lock.yaml
@@ -8,12 +8,15 @@ dependencies:
'@ant-design/cssinjs':
specifier: 2.1.2
version: 2.1.2(react-dom@19.2.0)(react@19.2.0)
- '@ant-design/icons':
- specifier: 6.3.2
- version: 6.3.2(react-dom@19.2.0)(react@19.2.0)
+ '@fontsource/inter':
+ specifier: 5.3.0
+ version: 5.3.0
antd:
specifier: 6.6.0
version: 6.6.0(react-dom@19.2.0)(react@19.2.0)
+ lucide-react:
+ specifier: 0.468.0
+ version: 0.468.0(react@19.2.0)
next:
specifier: 16.3.0
version: 16.3.0(@types/node@20.19.43)(react-dom@19.2.0)(react@19.2.0)
@@ -139,6 +142,10 @@ packages:
resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==}
dev: false
+ /@fontsource/inter@5.3.0:
+ resolution: {integrity: sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==}
+ dev: false
+
/@img/colour@1.1.0:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -1189,6 +1196,14 @@ packages:
string-convert: 0.2.1
dev: false
+ /lucide-react@0.468.0(react@19.2.0):
+ resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
+ dependencies:
+ react: 19.2.0
+ dev: false
+
/nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
diff --git a/admin-dashboard/scripts/check-design-contract.mjs b/admin-dashboard/scripts/check-design-contract.mjs
new file mode 100644
index 0000000..38fb09e
--- /dev/null
+++ b/admin-dashboard/scripts/check-design-contract.mjs
@@ -0,0 +1,68 @@
+import { readFileSync } from 'node:fs';
+
+const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
+const fail = (message) => {
+ throw new Error(`design contract: ${message}`);
+};
+const expectText = (source, value, context) => {
+ if (!source.includes(value)) fail(`${context} is missing ${value}`);
+};
+
+const globalCss = read('src/style/global.css');
+const dashboardCss = read('src/style/dashboard.module.css');
+const page = read('src/pages/index.tsx');
+const documentSource = read('src/pages/_document.tsx');
+const theme = read('src/theme/control-room-theme.tsx');
+const packageJson = JSON.parse(read('package.json'));
+
+expectText(globalCss, '9d048faaabe0429a6a8720bfbb31418544237b6b', 'semantic adapter pin');
+expectText(theme, 'fullstack-ai-infra/design-system@9d048faa', 'Ant theme pin');
+expectText(documentSource, "matchMedia('(prefers-color-scheme: dark)')", 'pre-paint system theme bootstrap');
+expectText(documentSource, "localStorage.getItem('waitqueue-theme')", 'legacy theme migration');
+expectText(documentSource, "dataset.themeReady=mode==='dark'?'false':'true'", 'dark first-paint guard');
+expectText(globalCss, "[data-theme-ready='false']", 'dark first-paint CSS guard');
+expectText(theme, 'colorTextPlaceholder: colors.foregroundSubtle', 'accessible placeholder mapping');
+expectText(theme, "dataset.themeReady = 'true'", 'hydrated theme readiness');
+
+const requiredTokens = [
+ '--ui-canvas:',
+ '--ui-navigation:',
+ '--ui-surface:',
+ '--ui-foreground:',
+ '--ui-primary:',
+ '--ui-ai:',
+ '--ui-border:',
+ '--ui-font-sans:',
+ '--ui-space-4:',
+ '--ui-radius-lg:',
+ '--ui-shadow-md:',
+ '--ui-duration-normal:',
+ '--ui-rail-width: 72px',
+ '--ui-sidebar-width: 256px',
+ '--ui-topbar-height: 60px',
+];
+for (const token of requiredTokens) expectText(globalCss, token, 'semantic token contract');
+
+if (/#[\da-f]{3,8}\b/i.test(dashboardCss) || /#[\da-f]{3,8}\b/i.test(page)) {
+ fail('product layout must use semantic variables instead of raw palette values');
+}
+
+for (const legacy of ['WaitQueue Workbench', 'Runtime Pulse', 'Delivery & Recovery', '#00c98b', '#17181b']) {
+ if (page.includes(legacy) || dashboardCss.includes(legacy)) fail(`legacy visual language remains: ${legacy}`);
+}
+
+expectText(page, 'data-product="waitqueue-console"', 'stable dashboard marker');
+expectText(page, 'aria-label="主模块导航"', 'primary navigation landmark');
+expectText(page, 'aria-label="队列目录"', 'queue catalog landmark');
+expectText(page, 'aria-label="运行模块"', 'rail menu name');
+expectText(page, 'tabIndex={-1}', 'skip-link focus target');
+expectText(dashboardCss, 'min-height: 44px', 'mobile touch targets');
+for (const view of ["key: 'overview'", "key: 'queues'", "key: 'deadLetters'", "key: 'diagnostics'"]) {
+ expectText(page, view, 'module navigation');
+}
+
+if (packageJson.dependencies['@ant-design/icons']) fail('product icons must not depend directly on @ant-design/icons');
+if (packageJson.dependencies['lucide-react'] !== '0.468.0') fail('lucide-react must stay aligned with the pinned design-system commit');
+if (packageJson.dependencies['@fontsource/inter'] !== '5.3.0') fail('Inter assets must stay aligned with the pinned design-system commit');
+
+console.log('Warm Agent Workspace contract verified.');
diff --git a/admin-dashboard/src/pages/_app.tsx b/admin-dashboard/src/pages/_app.tsx
index 2b98b9f..936a084 100644
--- a/admin-dashboard/src/pages/_app.tsx
+++ b/admin-dashboard/src/pages/_app.tsx
@@ -1,5 +1,8 @@
import Head from 'next/head';
import type { AppProps } from 'next/app';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/600.css';
import '../style/global.css';
import { ControlRoomTheme } from '../theme/control-room-theme';
@@ -8,7 +11,7 @@ export default function WaitQueueApp({ Component, pageProps }: AppProps) {
<>
-
+
diff --git a/admin-dashboard/src/pages/_document.tsx b/admin-dashboard/src/pages/_document.tsx
index d74e305..ee4c793 100644
--- a/admin-dashboard/src/pages/_document.tsx
+++ b/admin-dashboard/src/pages/_document.tsx
@@ -31,7 +31,14 @@ export default class WaitQueueDocument extends Document {
render() {
return (
-
+
+
+
diff --git a/admin-dashboard/src/pages/index.tsx b/admin-dashboard/src/pages/index.tsx
index 646c0fb..4086004 100644
--- a/admin-dashboard/src/pages/index.tsx
+++ b/admin-dashboard/src/pages/index.tsx
@@ -2,8 +2,10 @@ import {
Alert,
App as AntApp,
Badge,
+ Breadcrumb,
Button,
Card,
+ Descriptions,
Drawer,
Empty,
Form,
@@ -14,7 +16,6 @@ import {
Modal,
Pagination,
Progress,
- Segmented,
Select,
Skeleton,
Space,
@@ -26,32 +27,34 @@ import {
type TableColumnsType,
} from 'antd';
import {
- AppstoreOutlined,
- BarsOutlined,
- CheckCircleOutlined,
- CloudServerOutlined,
- CodeOutlined,
- DashboardOutlined,
- DatabaseOutlined,
- FieldTimeOutlined,
- MenuOutlined,
- MoonOutlined,
- PlusOutlined,
- RedoOutlined,
- ReloadOutlined,
- SafetyCertificateOutlined,
- SearchOutlined,
- SendOutlined,
- SettingOutlined,
- SunOutlined,
- ThunderboltOutlined,
- WarningOutlined,
-} from '@ant-design/icons';
+ Activity,
+ ArchiveRestore,
+ Boxes,
+ CheckCircle2,
+ Clock3,
+ Database,
+ Gauge,
+ LayoutDashboard,
+ ListTree,
+ Menu as MenuIcon,
+ Moon,
+ Plus,
+ RefreshCw,
+ RotateCcw,
+ Search,
+ Send,
+ Server,
+ Settings,
+ ShieldCheck,
+ Sun,
+ TriangleAlert,
+ Waypoints,
+} from 'lucide-react';
import type { NextPage } from 'next';
import Head from 'next/head';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useColorMode } from '../theme/control-room-theme';
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import styles from '../style/dashboard.module.css';
+import { useColorMode } from '../theme/control-room-theme';
const { Header, Sider, Content } = Layout;
const REQUEST_TIMEOUT_MS = 10_000;
@@ -167,6 +170,13 @@ const DEFAULT_QUEUE_VALUES: QueueFormValues = {
expire: '0 */5 * * * *',
};
+const VIEW_COPY: Record = {
+ overview: { title: '运行总览', description: '队列积压、容量与恢复状态' },
+ queues: { title: '队列详情', description: '运行快照与调度配置' },
+ deadLetters: { title: '死信处理', description: '审阅并安全重放失败任务' },
+ diagnostics: { title: '服务诊断', description: '探针与指标契约' },
+};
+
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit): Promise {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -265,12 +275,12 @@ function displayHook(value: string): string {
}
}
-function queueState(queue: QueueOverviewItem): { label: string; color: 'success' | 'warning' | 'error' | 'default' } {
- if ((queue.deadLetters ?? 0) > 0) return { label: '需处理', color: 'error' };
- if (queue.waiting > 0 && queue.running >= queue.concurrency) return { label: '容量已满', color: 'warning' };
- if (queue.waiting > 0 || (queue.retrying ?? 0) > 0) return { label: '有积压', color: 'warning' };
- if (queue.running > 0) return { label: '运行中', color: 'success' };
- return { label: '空闲', color: 'default' };
+function queueState(queue: QueueOverviewItem): { label: string; color: 'success' | 'warning' | 'error' | 'default'; badge: 'success' | 'warning' | 'error' | 'default' } {
+ if ((queue.deadLetters ?? 0) > 0) return { label: '需要处理', color: 'error', badge: 'error' };
+ if (queue.waiting > 0 && queue.running >= queue.concurrency) return { label: '容量已满', color: 'warning', badge: 'warning' };
+ if (queue.waiting > 0 || (queue.retrying ?? 0) > 0) return { label: '存在积压', color: 'warning', badge: 'warning' };
+ if (queue.running > 0) return { label: '运行中', color: 'success', badge: 'success' };
+ return { label: '空闲', color: 'default', badge: 'default' };
}
function serviceState(overview: QueueOverview | null, error: string, health: HealthState, loading: boolean): ServiceState {
@@ -284,17 +294,43 @@ function serviceState(overview: QueueOverview | null, error: string, health: Hea
}
function stateTag(state: ServiceState) {
- const map: Record = {
- SYNCING: { color: 'processing', icon: , text: 'SYNCING' },
- ONLINE: { color: 'success', icon: , text: 'ONLINE' },
- DEGRADED: { color: 'warning', icon: , text: 'DEGRADED' },
- STALE: { color: 'warning', icon: , text: 'STALE' },
- OFFLINE: { color: 'error', icon: , text: 'OFFLINE' },
+ const map: Record = {
+ SYNCING: { color: 'processing', icon: , text: '正在同步' },
+ ONLINE: { color: 'success', icon: , text: '服务在线' },
+ DEGRADED: { color: 'warning', icon: , text: '依赖异常' },
+ STALE: { color: 'warning', icon: , text: '快照陈旧' },
+ OFFLINE: { color: 'error', icon: , text: '服务离线' },
};
const item = map[state];
return {item.text};
}
+function ConsolePageHeader({
+ eyebrow,
+ title,
+ description,
+ meta,
+ actions,
+}: {
+ eyebrow: string;
+ title: ReactNode;
+ description: ReactNode;
+ meta?: ReactNode;
+ actions?: ReactNode;
+}) {
+ return (
+
+
+
{eyebrow}
+
{title}
+
{description}
+ {meta &&
{meta}
}
+
+ {actions && {actions}
}
+
+ );
+}
+
function SidebarCatalog({
overview,
queues,
@@ -314,34 +350,42 @@ function SidebarCatalog({
}) {
return (
-
-
{metric(overview?.summary.queueCount)} 队列
-
{metric(overview?.summary.running)} 运行
-
{metric(overview?.summary.waiting)} 等待
+
+
+
+ WaitQueue
+ 队列运行中心
+
+
+
}
+ prefix={}
value={query}
onChange={(event) => onQuery(event.target.value)}
- placeholder="搜索 namespace / origin / ID"
+ placeholder="搜索队列或回调源"
aria-label="搜索队列"
/>
-
-
-
SCHEDULER QUEUES
-
{queues.length}
+
+
+ 运行队列
+ {queues.length} / {overview?.summary.queueCount ?? 0}
+
+
- } block onClick={onCreate}>注册队列
- 自动刷新 · 10 秒
+ } block onClick={onCreate}>注册队列
+ 每 10 秒自动刷新
);
@@ -402,7 +450,7 @@ const Dashboard: NextPage = () => {
try {
const data = await getJson
('/waitqueue/admin/overview');
setOverview(data);
- setActiveQueueId((current) => current ?? data.queues[0]?.queueId ?? null);
+ setActiveQueueId((current) => data.queues.some((queue) => queue.queueId === current) ? current : data.queues[0]?.queueId ?? null);
setError('');
} catch (reason) {
setError(reason instanceof Error ? reason.message : '无法连接 WaitQueue 服务');
@@ -468,7 +516,6 @@ const Dashboard: NextPage = () => {
[activeQueueId, overview]
);
const currentDeadLetters = deadLetterQueueId === activeQueue?.queueId ? deadLetters : null;
-
const currentServiceState = serviceState(overview, error, health, loading);
const loadDeadLetters = useCallback(async (queueId: number, page = 1) => {
@@ -585,7 +632,7 @@ const Dashboard: NextPage = () => {
content: `队列 ${activeQueue.namespace} · 任务 ${item.taskId} · 已重试 ${item.retryCount} 次`,
okText: '重放并重新入队',
cancelText: '取消',
- icon: ,
+ icon: ,
async onOk() {
try {
await postJson('/waitqueue/admin/deadLetters/replay', {
@@ -603,26 +650,28 @@ const Dashboard: NextPage = () => {
});
};
+ const openQueueView = (queueId: number) => {
+ setActiveQueueId(queueId);
+ setView('queues');
+ setMobileCatalogOpen(false);
+ };
+
const menuItems: MenuProps['items'] = [
- { key: 'overview', icon: , label: '总览' },
- { key: 'queues', icon: , label: '队列' },
- { key: 'deadLetters', icon: , label: '死信' },
- { key: 'diagnostics', icon: , label: '诊断' },
+ { key: 'overview', icon: , label: '总览' },
+ { key: 'queues', icon: , label: '队列' },
+ { key: 'deadLetters', icon: , label: '死信' },
+ { key: 'diagnostics', icon: , label: '诊断' },
];
const queueColumns: TableColumnsType = [
{
- title: '队列 / 回调源',
+ title: '队列',
key: 'queue',
- width: 250,
- fixed: 'left',
+ width: 230,
render: (_, queue) => (
-
Q-{String(queue.queueId).padStart(3, '0')}
-
-
- {displayHook(queue.hookUrl)}
-
+
+
Q-{String(queue.queueId).padStart(3, '0')} · {displayHook(queue.hookUrl)}
),
},
@@ -636,20 +685,20 @@ const Dashboard: NextPage = () => {
},
},
{
- title: '积压 / 最老等待',
+ title: '等待',
key: 'backlog',
- width: 180,
+ width: 150,
render: (_, queue) => (
0 ? styles.warningText : undefined}>{queue.waiting}
- {formatAge(queue.oldestWaitingAgeSeconds, queue.waiting)}
+ {formatAge(queue.oldestWaitingAgeSeconds, queue.waiting)}
),
},
{
- title: '运行槽位',
+ title: '运行容量',
key: 'capacity',
- width: 170,
+ width: 180,
render: (_, queue) => (
{queue.running} / {queue.concurrency}
@@ -658,37 +707,22 @@ const Dashboard: NextPage = () => {
),
},
{
- title: 'Retry',
- dataIndex: 'retrying',
- width: 90,
- align: 'right',
- render: (value: number | undefined) =>
0 ? styles.warningText : undefined}>{metric(value)},
- },
- {
- title: 'DLQ',
- dataIndex: 'deadLetters',
- width: 90,
- align: 'right',
- render: (value: number | undefined) =>
0 ? styles.dangerText : undefined}>{metric(value)},
- },
- {
- title: '投递失败',
+ title: '失败',
key: 'failures',
- width: 110,
+ width: 120,
align: 'right',
- render: (_, queue) =>
0 ? styles.dangerText : undefined}>{metric(queue.callbacks?.failure)},
+ render: (_, queue) => (
+
0 || (queue.deadLetters ?? 0) > 0 ? styles.dangerText : undefined}>
+ {metric(queue.callbacks?.failure)} / {metric(queue.deadLetters)} DLQ
+
+ ),
},
{
- title: '操作',
+ title: '',
key: 'actions',
- width: 128,
- fixed: 'right',
- render: (_, queue) => (
-
- } onClick={() => openTaskModal(queue)} />
- } onClick={() => openQueueModal(queue)} />
-
- ),
+ width: 80,
+ align: 'right',
+ render: (_, queue) =>
,
},
];
@@ -697,7 +731,7 @@ const Dashboard: NextPage = () => {
{ title: '失败原因', dataIndex: 'reason', width: 150, render: (value: DeadLetterItem['reason']) =>
{value === 'lease_expired' ? '租约过期' : '回调失败'} },
{ title: '重试次数', dataIndex: 'retryCount', width: 110, align: 'right' },
{ title: '失败时间', dataIndex: 'failedAt', width: 180, render: formatTimestamp },
- { title: '操作', key: 'action', width: 110, fixed: 'right', render: (_, item) =>
} onClick={() => replayDeadLetter(item)}>重放 },
+ { title: '操作', key: 'action', width: 110, fixed: 'right', render: (_, item) =>
} onClick={() => replayDeadLetter(item)}>重放 },
];
const catalog = (
@@ -709,6 +743,7 @@ const Dashboard: NextPage = () => {
onQuery={setQuery}
onSelect={(queueId) => {
setActiveQueueId(queueId);
+ if (view !== 'deadLetters') setView('queues');
setMobileCatalogOpen(false);
}}
onCreate={() => {
@@ -718,256 +753,376 @@ const Dashboard: NextPage = () => {
/>
);
+ const renderOverview = () => (
+ <>
+
已注册 {overview?.summary.queueCount ?? 0} 个队列 · 每 10 秒自动同步}
+ actions={} onClick={() => openQueueModal()}>注册队列}
+ />
+
+
+ 0 ? styles.metricWarning : ''}`}>
+ 等待任务
+
+ 最老等待:{formatAge(overview?.summary.oldestWaitingAgeSeconds, overview?.summary.waiting)}
+
+
+
+
+
+
+ 0 ? styles.metricWarning : ''}`}>
+ 延迟重试
+
+ 等待下一次回队调度
+
+ 0 ? styles.metricDanger : ''}`}>
+
+
+
+
+
+
+ }
+ extra={{overview?.summary.queueCount ?? 0} 个队列}
+ >
+ {(overview?.queues.length ?? 0) > 0 ? (
+ <>
+
+
+ {(overview?.queues ?? []).map((queue) => {
+ const state = queueState(queue);
+ return (
+
+ );
+ })}
+
+ >
+ ) : (
+
+ } onClick={() => openQueueModal()}>注册第一条队列
+
+ )}
+
+
+ 投递与恢复当前进程累计值}
+ >
+
+
回调成功{metric(overview?.summary.callbackSuccesses)}
+
回调失败 0 ? styles.dangerText : undefined}>{metric(overview?.summary.callbackFailures)}
+
成功认领{metric(overview?.summary.claims)}
+
租约恢复{metric(overview?.summary.recovered)}
+
+
+
+ 统计自 {formatTimestamp(overview?.metricsStartedAt)},服务重启后重新计数。
+
+
+
+ >
+ );
+
+ const renderQueueDetail = () => activeQueue ? (
+ <>
+
+ {queueState(activeQueue).label}
+ 配置更新于 {formatTimestamp(activeQueue.updatedAt)}
+ >
+ }
+ actions={} onClick={() => openQueueModal(activeQueue)}>编辑配置}
+ />
+
+
+ 0 ? styles.metricWarning : ''}`}>
+ 等待任务
+
+ 最老等待:{formatAge(activeQueue.oldestWaitingAgeSeconds, activeQueue.waiting)}
+
+
+
+
+ 当前可用槽位:{activeQueue.available}
+
+ 0 ? styles.metricWarning : ''}`}>
+ 延迟重试
+
+ 失败后按退避策略回队
+
+ 0 ? styles.metricDanger : ''}`}>
+
+
+
+
+
+
+ }>
+
+
{activeQueue.utilization}%容量利用率
+
+
+
+
+
+ 调度策略服务端生效中的 Cron 配置}>
+
+
运行任务{activeQueue.crontab.run}
+
检查任务{activeQueue.crontab.check}
+
回收过期{activeQueue.crontab.expire}
+
+
+
+ 投递生命周期该队列在当前进程中的累计状态变化}>
+
+
回调成功{metric(activeQueue.callbacks?.success)}
+
回调失败 0 ? styles.dangerText : undefined}>{metric(activeQueue.callbacks?.failure)}
+
任务认领{metric(activeQueue.claims?.claimed)}
+
租约恢复{metric(activeQueue.claims?.recovered)}
+
+
+
+ >
+ ) : (
+
+
+
+ );
+
+ const renderDeadLetters = () => (
+ <>
+ entryId 仅用于代际校验,不进入指标标签或应用日志。}
+ actions={
+ <>
+