From a21d0bb7bac53a661fa1d43a2e2cabe2b5f6b92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Wed, 12 Aug 2026 12:36:30 +0800 Subject: [PATCH 1/3] add neutral themes and Pages demo --- .github/workflows/ci.yml | 9 ++ .github/workflows/pages.yml | 92 +++++++++++ README.md | 9 +- admin-dashboard/README.md | 22 ++- admin-dashboard/next.config.js | 28 ++-- admin-dashboard/package.json | 4 +- .../scripts/check-design-contract.mjs | 82 +++++++--- .../scripts/check-pages-export.mjs | 56 +++++++ admin-dashboard/src/data/demo-snapshot.ts | 153 ++++++++++++++++++ admin-dashboard/src/pages/_app.tsx | 2 +- admin-dashboard/src/pages/_document.tsx | 2 +- admin-dashboard/src/pages/index.tsx | 111 +++++++++---- .../src/style/dashboard.module.css | 5 + admin-dashboard/src/style/global.css | 140 ++++++++-------- .../src/theme/control-room-theme.tsx | 57 ++++--- 15 files changed, 606 insertions(+), 166 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 admin-dashboard/scripts/check-pages-export.mjs create mode 100644 admin-dashboard/src/data/demo-snapshot.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf88133..20b9561 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,15 @@ jobs: set -Eeuo pipefail corepack pnpm build + - name: Build and verify static dashboard demo + shell: bash + env: + PAGES_BASE_PATH: /waitqueue.js + run: | + set -Eeuo pipefail + corepack pnpm --dir admin-dashboard build:pages + corepack pnpm --dir admin-dashboard check:pages + dependency-audit: name: Dependency audit runs-on: ubuntu-24.04 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..135a7c8 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,92 @@ +name: Pages + +on: + push: + branches: + - master + paths: + - 'admin-dashboard/**' + - '.github/workflows/pages.yml' + - 'README.md' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +env: + NODE_VERSION: 24.18.0 + PNPM_VERSION: 8.15.9 + +jobs: + build: + name: Build static demo + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ env.NODE_VERSION }} + check-latest: false + + - name: Activate pnpm + shell: bash + run: | + set -Eeuo pipefail + corepack enable + corepack prepare "pnpm@${PNPM_VERSION}" --activate + + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v6 + + - name: Install dashboard dependencies + run: corepack pnpm --dir admin-dashboard install --frozen-lockfile + + - name: Validate dashboard + shell: bash + run: | + set -Eeuo pipefail + corepack pnpm --dir admin-dashboard typecheck + corepack pnpm --dir admin-dashboard check:design + + - name: Build static demo + run: corepack pnpm --dir admin-dashboard build:pages + env: + PAGES_BASE_PATH: ${{ steps.pages.outputs.base_path }} + + - name: Check static export contract + run: corepack pnpm --dir admin-dashboard check:pages + + - name: Disable Jekyll processing + run: touch admin-dashboard/out/.nojekyll + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: admin-dashboard/out + + deploy: + name: Deploy static demo + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/README.md b/README.md index 9aa6393..b73741b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # waitqueue.js [![CI](https://github.com/PeterGuy326/waitqueue.js/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/PeterGuy326/waitqueue.js/actions/workflows/ci.yml) +[![Pages](https://github.com/PeterGuy326/waitqueue.js/actions/workflows/pages.yml/badge.svg?branch=master)](https://github.com/PeterGuy326/waitqueue.js/actions/workflows/pages.yml) + +[在线参观控制台(只读快照)](https://peterguy326.github.io/waitqueue.js/) · [一条命令启动](#一条命令启动) + +> 在线演示使用仓库内置的脱敏快照,不连接 WaitQueue 服务;注册或更新队列、提交任务和 DLQ 重放仅在本地或自托管部署中可用。 一个轻量的 HTTP 回调任务队列与并发调度器。业务系统只提交 `taskId`;WaitQueue 负责排队、并发占位和周期检查,真正的任务仍由业务回调服务执行。 @@ -14,7 +19,7 @@ - Redis FIFO list 保存等待任务,hash/ZSET 保存 claim、退避与死信运行态; - Lua 脚本原子完成领取、租约恢复、失败转移和重放,不突破队列并发上限; - HTTP 回调驱动 `run`、`check`、`expire` 三类业务动作; -- Web 控制台展示真实 waiting/running/retrying/DLQ、等待年龄与回调/领取计数,并支持注册队列和提交任务; +- 自托管 Web 控制台展示真实 waiting/running/retrying/DLQ、等待年龄与回调/领取计数,并支持注册队列和提交任务; - 核心服务无任务载荷、图表、消息总线等额外依赖,保持小而明确。 适合构建、导出、媒体处理、批量通知或第三方限流调用。不适合需要任务历史、优先级、复杂工作流或跨节点调度选主的系统。 @@ -644,7 +649,7 @@ curl -X POST http://127.0.0.1:3000/waitqueue/admin/deadLetters/replay \ 控制台位于 `admin-dashboard/`,不是 mock 模板,也不是后端启动依赖。它提供: - 10 秒自动刷新,页面不可见时暂停轮询; -- 基于 Ant Design 6、对齐 fullstack-ai-infra Warm Agent Workspace 的浅/深主题与移动端布局; +- 基于 Ant Design 6 的中性白色/暗黑主题,并参考 fullstack-ai-infra 的布局、密度、字体、图标与无障碍约束; - 真实 waiting、running、retrying、DLQ、最老等待与容量利用率; - 当前进程 callback、claim、recovery 计数及起始时间; - 72px 模块栏、256px 队列上下文目录、60px 顶栏与独立队列详情页; diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md index 5f04806..58f335a 100644 --- a/admin-dashboard/README.md +++ b/admin-dashboard/README.md @@ -1,10 +1,10 @@ # WaitQueue 队列运行中心 -waitqueue.js 的轻量实时运维控制台。它直接读取后端队列配置、Redis 运行快照和当前进程计数,不使用 mock 数据,也不展示当前系统无法证明的历史趋势。 +waitqueue.js 的轻量运维控制台。自托管模式直接读取后端队列配置、Redis 运行快照和当前进程计数,不伪造历史趋势;[GitHub Pages 在线演示](https://peterguy326.github.io/waitqueue.js/) 是唯一的静态访客模式,使用仓库内置的固定脱敏快照且不发起后端请求。 -界面遵循 [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 `Menu`,指标和响应式栅格使用 `Row / Col / Card / Statistic`,结构化数据使用 `Table / Descriptions`,表单、抽屉、弹窗及反馈也统一由 Ant Design 6 提供。 +界面采用 Ant Design 6 的中性企业管理台主题:浅色以白色表面与灰色画布为主,深色使用中性黑灰层级;蓝色主要用于主操作、链接、选中态、焦点反馈、产品识别和信息强调,绿色只表达成功状态。布局与交互参考 [fullstack-ai-infra/design-system@9d048faa](https://github.com/fullstack-ai-infra/design-system/tree/9d048faaabe0429a6a8720bfbb31418544237b6b) 的结构约束,包括 72px 模块栏、256px 队列上下文栏、60px 顶栏、Inter/Lucide、紧凑密度、响应式触控目标与无障碍行为;本项目不采用该版本的暖色与 sage palette。 -设计系统当前尚未发布稳定 npm 版本,且其 React 18 peer contract 与本项目 React 19 不兼容,因此这里使用轻量 semantic adapter 将上游 token 映射到 Ant Design `ConfigProvider`,不引入重复的 Radix、Tailwind 或状态管理运行时。待设计系统正式支持 React 19 后,可直接切换到包依赖。 +设计系统当前尚未发布稳定 npm 版本,且其 React 18 peer contract 与本项目 React 19 不兼容,因此这里使用轻量 semantic adapter 将 WaitQueue 的中性产品 token 映射到 Ant Design `ConfigProvider`,不引入重复的 Radix、Tailwind 或状态管理运行时。未来升级到正式包时仍需保留产品级中性主题覆盖。 ## 页面怎么用 @@ -19,6 +19,18 @@ waitqueue.js 的轻量实时运维控制台。它直接读取后端队列配置 当前没有任务历史存储,因此页面不会展示吞吐趋势、成功率、平均耗时或任务明细。 +## 在线参观(只读) + +访问 [https://peterguy326.github.io/waitqueue.js/](https://peterguy326.github.io/waitqueue.js/) 可直接查看控制台。静态演示保留搜索、模块导航、队列切换、死信分页和浅色/深色主题,但不会连接真实 API;注册或更新队列、提交任务、刷新运行态和死信重放均不可用。演示数据只包含合成 ID 与 `example.invalid` 回调地址,不包含真实任务、租户、token 或业务 URL。 + +Pages 构建与实时控制台共用一套组件和 Ant Design token: + +```bash +corepack pnpm --dir admin-dashboard build:pages +``` + +该命令输出 `admin-dashboard/out/`;普通 `build` 仍生成带服务端代理的 standalone 产物。任何 `WAITQUEUE_API_TOKEN` 都不会进入 Pages 构建。 + ## 最快启动(推荐) 先在仓库根目录按 [根 README](../README.md) 创建 `.env`,然后运行: @@ -112,8 +124,8 @@ admin-dashboard/ ├── src/pages/_document.tsx # Ant Design 服务端样式提取 ├── src/pages/index.tsx # 数据读取、交互与四个运行页面 ├── src/pages/api/waitqueue/ # 运行时白名单代理与 token 注入 -├── src/style/global.css # design-system 语义适配与基础样式 -├── src/style/dashboard.module.css # Warm Queue Console 布局与响应式样式 +├── src/style/global.css # 中性产品 token 与基础样式 +├── src/style/dashboard.module.css # WaitQueue Console 布局与响应式样式 ├── src/theme/ # 语义 token 到 Ant Design 的映射 ├── next.config.js # API 同源代理 └── .env.example # 后端地址示例 diff --git a/admin-dashboard/next.config.js b/admin-dashboard/next.config.js index 1d7eef9..46e5687 100644 --- a/admin-dashboard/next.config.js +++ b/admin-dashboard/next.config.js @@ -1,16 +1,26 @@ /** @type {import('next').NextConfig} */ +const isPages = process.env.WAITQUEUE_PAGES_BUILD === 'true'; +const pagesBasePath = process.env.PAGES_BASE_PATH || '/waitqueue.js'; + module.exports = { - output: 'standalone', + output: isPages ? 'export' : 'standalone', + basePath: isPages ? pagesBasePath : '', + trailingSlash: isPages, + pageExtensions: isPages ? ['tsx'] : ['tsx', 'ts'], reactStrictMode: true, poweredByHeader: false, agentRules: false, allowedDevOrigins: ['127.0.0.1'], - async rewrites() { - return [ - { - source: '/waitqueue/:path*', - destination: '/api/waitqueue/:path*', - }, - ]; - }, + ...(isPages + ? {} + : { + async rewrites() { + return [ + { + source: '/waitqueue/:path*', + destination: '/api/waitqueue/:path*', + }, + ]; + }, + }), }; diff --git a/admin-dashboard/package.json b/admin-dashboard/package.json index 9fa7f8f..75e163a 100644 --- a/admin-dashboard/package.json +++ b/admin-dashboard/package.json @@ -10,9 +10,11 @@ "scripts": { "dev": "next dev --webpack -p 3001", "build": "next build --webpack", + "build:pages": "WAITQUEUE_PAGES_BUILD=true NEXT_PUBLIC_WAITQUEUE_DEMO=true next build --webpack", "start": "next start -p 3001", "typecheck": "next typegen && tsc --noEmit", - "check:design": "node scripts/check-design-contract.mjs" + "check:design": "node scripts/check-design-contract.mjs", + "check:pages": "node scripts/check-pages-export.mjs" }, "dependencies": { "@ant-design/cssinjs": "2.1.2", diff --git a/admin-dashboard/scripts/check-design-contract.mjs b/admin-dashboard/scripts/check-design-contract.mjs index 2c1f7b6..1435a8a 100644 --- a/admin-dashboard/scripts/check-design-contract.mjs +++ b/admin-dashboard/scripts/check-design-contract.mjs @@ -25,15 +25,19 @@ 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(globalCss, '9d048faaabe0429a6a8720bfbb31418544237b6b', 'structural reference pin'); +expectText(theme, 'fullstack-ai-infra/design-system@9d048faa', 'Ant structural reference pin'); expectText(documentSource, "matchMedia('(prefers-color-scheme: dark)')", 'pre-paint system theme bootstrap'); -expectText(documentSource, "localStorage.getItem('waitqueue-theme')", 'legacy theme migration'); +expectText(documentSource, "localStorage.getItem('waitqueue-color-mode')", 'neutral theme storage'); +expectText(documentSource, "localStorage.getItem('waitqueue-warm-theme')", 'warm theme migration'); +expectText(documentSource, "localStorage.getItem('waitqueue-theme')", 'original 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: supportingText', 'accessible placeholder mapping'); expectText(theme, 'colorTextTertiary: supportingText', 'accessible tertiary text mapping'); expectText(theme, 'cssVar: { key: `waitqueue-${mode}` }', 'isolated light and dark Ant variable scopes'); +expectText(theme, 'colorPrimaryActive: colors.primary', 'accessible active primary mapping'); +expectText(theme, 'colorLinkActive: colors.primary', 'accessible active link mapping'); expectText(theme, 'defaultColor: colors.foreground', 'readable Ant button mapping'); expectText(theme, 'itemColor: colors.foregroundMuted', 'readable Ant navigation mapping'); expectText(theme, 'lastItemColor: colors.foreground', 'readable Ant breadcrumb mapping'); @@ -45,7 +49,6 @@ const requiredTokens = [ '--ui-surface:', '--ui-foreground:', '--ui-primary:', - '--ui-ai:', '--ui-border:', '--ui-control-border:', '--ui-font-sans:', @@ -59,20 +62,50 @@ const requiredTokens = [ ]; for (const token of requiredTokens) expectText(globalCss, token, 'semantic token contract'); -const darkBlock = globalCss.match(/:root\[data-theme='dark'\]\s*{([\s\S]*?)\n}/)?.[1]; -if (!darkBlock) fail('dark semantic token block is missing'); -const darkToken = (name) => { - const value = darkBlock.match(new RegExp(`${name}:\\s*(#[\\da-f]{6})`, 'i'))?.[1]; - if (!value) fail(`dark semantic token is missing ${name}`); +const cssBlocks = { + light: globalCss.match(/:root,\s*:root\[data-theme='light'\]\s*{([\s\S]*?)\n}/)?.[1], + dark: globalCss.match(/:root\[data-theme='dark'\]\s*{([\s\S]*?)\n}/)?.[1], +}; +const cssToken = (mode, name) => { + const block = cssBlocks[mode]; + if (!block) fail(`${mode} semantic token block is missing`); + const value = block.match(new RegExp(`${name}:\\s*(#[\\da-f]{6})`, 'i'))?.[1]; + if (!value) fail(`${mode} semantic token is missing ${name}`); return value; }; -const darkSurface = darkToken('--ui-surface-raised'); -for (const [role, minimum] of [['--ui-foreground', 7], ['--ui-foreground-muted', 4.5], ['--ui-primary', 4.5]]) { - const ratio = contrast(darkToken(role), darkSurface); - if (ratio < minimum) fail(`${role} dark contrast ${ratio.toFixed(2)} is below ${minimum}`); -} -if (contrast(darkToken('--ui-control-border'), darkSurface) < 3) { - fail('dark control boundary contrast is below 3'); + +const expectedPalette = { + light: { + '--ui-canvas': '#f5f5f5', '--ui-navigation': '#ffffff', '--ui-surface': '#ffffff', + '--ui-primary': '#0958d9', '--ui-primary-hover': '#003eb3', '--ui-primary-soft': '#e6f4ff', + }, + dark: { + '--ui-canvas': '#141414', '--ui-navigation': '#1f1f1f', '--ui-surface': '#1f1f1f', + '--ui-primary': '#69b1ff', '--ui-primary-hover': '#91caff', '--ui-primary-soft': '#111d2c', + }, +}; +for (const mode of ['light', 'dark']) { + for (const [role, expected] of Object.entries(expectedPalette[mode])) { + const actual = cssToken(mode, role).toLowerCase(); + if (actual !== expected) fail(`${mode} ${role} must be ${expected}, received ${actual}`); + } + const surface = cssToken(mode, '--ui-surface-raised'); + for (const [role, minimum] of [ + ['--ui-foreground', 7], + ['--ui-foreground-muted', 4.5], + ['--ui-foreground-subtle', 4.5], + ['--ui-primary', 4.5], + ['--ui-primary-hover', 4.5], + ]) { + const ratio = contrast(cssToken(mode, role), surface); + if (ratio < minimum) fail(`${mode} ${role} contrast ${ratio.toFixed(2)} is below ${minimum}`); + } + if (contrast(cssToken(mode, '--ui-control-border'), surface) < 3) { + fail(`${mode} control boundary contrast is below 3`); + } + if (contrast(cssToken(mode, '--ui-primary-foreground'), cssToken(mode, '--ui-primary')) < 4.5) { + fail(`${mode} primary button contrast is below 4.5`); + } } const themeBlock = (mode) => theme.match(new RegExp(`${mode}: \\{([\\s\\S]*?)\\n \\},`))?.[1]; @@ -90,6 +123,17 @@ for (const mode of ['light', 'dark']) { } } +const retiredWarmPalette = [ + '#f5f1e8', '#eee9df', '#e6e1d7', '#fbf9f4', '#fffdf8', '#f0ece3', + '#5f735e', '#50644f', '#e2e9df', '#1e201d', '#242622', '#292b27', + '#272925', '#2e302c', '#344035', '#a1b69d', '#b2c6ae', +]; +for (const color of retiredWarmPalette) { + if (globalCss.toLowerCase().includes(color) || theme.toLowerCase().includes(color)) { + fail(`retired green/warm palette remains: ${color}`); + } +} + 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'); } @@ -126,7 +170,7 @@ for (const view of ["key: 'overview'", "key: 'queues'", "key: 'deadLetters'", "k } 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'); +if (packageJson.dependencies['lucide-react'] !== '0.468.0') fail('lucide-react must stay aligned with the structural design-system reference'); +if (packageJson.dependencies['@fontsource/inter'] !== '5.3.0') fail('Inter assets must stay aligned with the structural design-system reference'); -console.log('Warm Agent Workspace contract verified.'); +console.log('WaitQueue neutral Ant console contract verified.'); diff --git a/admin-dashboard/scripts/check-pages-export.mjs b/admin-dashboard/scripts/check-pages-export.mjs new file mode 100644 index 0000000..7acebed --- /dev/null +++ b/admin-dashboard/scripts/check-pages-export.mjs @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = new URL('../', import.meta.url); +const out = new URL('out/', root); +const fail = (message) => { throw new Error(`pages export: ${message}`); }; +const read = (path) => readFileSync(new URL(path, root), 'utf8'); + +if (!existsSync(new URL('out/index.html', root))) fail('out/index.html is missing'); +if (!existsSync(new URL('out/404.html', root))) fail('out/404.html is missing'); + +const html = read('out/index.html'); +const pageSource = read('src/pages/index.tsx'); +for (const marker of [ + 'WaitQueue · 只读演示', + '访客演示 · 只读快照', + '当前页面使用仓库内置的脱敏示例数据', + 'billing-export', + '/waitqueue.js/_next/', +]) { + if (!html.includes(marker)) fail(`static HTML is missing ${marker}`); +} + +const files = []; +const walk = (directory) => { + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + if (statSync(path).isDirectory()) walk(path); + else files.push(path); + } +}; +walk(out.pathname); + +if (files.some((path) => path.includes('/api/'))) fail('API route output must not be published'); +if (files.some((path) => path.endsWith('server.js'))) fail('Node server output must not be published'); + +const browserAssets = files + .filter((path) => /\.(?:html|js)$/.test(path)) + .map((path) => readFileSync(path, 'utf8')) + .join('\n'); +if (/WAITQUEUE_API_TOKEN|DASHBOARD_ALLOWED_HOSTS/.test(browserAssets)) fail('server-only configuration leaked into the export'); +if (/https?:\/\/(?:mock-hook|localhost|127\.0\.0\.1)/.test(browserAssets)) fail('local service address leaked into the export'); +if (/\/waitqueue\/(?:admin|health|queue|scheduler)/.test(browserAssets)) fail('live WaitQueue request path leaked into the export'); +if (!browserAssets.includes('example.invalid')) fail('sanitized demonstration origins are missing'); + +for (const control of [ + 'disabled={DEMO_MODE} onClick={() => openQueueModal()', + 'disabled={DEMO_MODE} onClick={() => openQueueModal(activeQueue)}', + 'disabled={DEMO_MODE} onClick={() => openTaskModal(activeQueue)}', + 'disabled={DEMO_MODE} onClick={() => replayDeadLetter(item)}', + 'htmlType="submit" disabled={DEMO_MODE}', +]) { + if (!pageSource.includes(control)) fail(`read-only control guard is missing: ${control}`); +} + +console.log('WaitQueue Pages export contract verified.'); diff --git a/admin-dashboard/src/data/demo-snapshot.ts b/admin-dashboard/src/data/demo-snapshot.ts new file mode 100644 index 0000000..c646b70 --- /dev/null +++ b/admin-dashboard/src/data/demo-snapshot.ts @@ -0,0 +1,153 @@ +export interface DemoQueueCrontab { + run: string; + check: string; + expire: string; +} + +export interface DemoQueueOverviewItem { + queueId: number; + namespace: string; + hookUrl: string; + concurrency: number; + waiting: number; + running: number; + available: number; + utilization: number; + retrying: number; + deadLetters: number; + oldestWaitingAt: string | null; + oldestWaitingAgeSeconds: number | null; + callbacks: { success: number; failure: number }; + claims: { claimed: number; recovered: number }; + crontab: DemoQueueCrontab; + updatedAt: string; +} + +export interface DemoQueueOverview { + generatedAt: string; + metricsStartedAt: string; + summary: { + queueCount: number; + waiting: number; + running: number; + capacity: number; + utilization: number; + retrying: number; + deadLetters: number; + oldestWaitingAt: string; + oldestWaitingAgeSeconds: number; + callbackSuccesses: number; + callbackFailures: number; + claims: number; + recovered: number; + }; + queues: DemoQueueOverviewItem[]; +} + +export interface DemoDeadLetterItem { + entryId: string; + taskId: string; + retryCount: number; + failedAt: string; + reason: 'callback_failed' | 'lease_expired'; +} + +export interface DemoDeadLetterPage { + total: number; + offset: number; + limit: number; + items: DemoDeadLetterItem[]; +} + +export const DEMO_SNAPSHOT_AT = '2026-08-11T11:04:43.000Z'; + +export const DEMO_OVERVIEW: DemoQueueOverview = { + generatedAt: DEMO_SNAPSHOT_AT, + metricsStartedAt: '2026-08-11T08:01:16.000Z', + summary: { + queueCount: 3, + waiting: 12, + running: 4, + capacity: 10, + utilization: 40, + retrying: 2, + deadLetters: 5, + oldestWaitingAt: '2026-08-11T07:32:43.000Z', + oldestWaitingAgeSeconds: 12_720, + callbackSuccesses: 72, + callbackFailures: 6, + claims: 78, + recovered: 3, + }, + queues: [ + { + queueId: 1, + namespace: 'billing-export', + hookUrl: 'https://worker.example.invalid/billing/callback', + concurrency: 5, + waiting: 12, + running: 4, + available: 1, + utilization: 80, + retrying: 2, + deadLetters: 0, + oldestWaitingAt: '2026-08-11T07:32:43.000Z', + oldestWaitingAgeSeconds: 12_720, + callbacks: { success: 42, failure: 2 }, + claims: { claimed: 44, recovered: 1 }, + crontab: { run: '*/2 * * * * *', check: '*/10 * * * * *', expire: '0 */5 * * * *' }, + updatedAt: '2026-08-11T10:56:00.000Z', + }, + { + queueId: 2, + namespace: 'media-transcode', + hookUrl: 'https://media.example.invalid/transcode/callback', + concurrency: 3, + waiting: 0, + running: 0, + available: 3, + utilization: 0, + retrying: 0, + deadLetters: 0, + oldestWaitingAt: null, + oldestWaitingAgeSeconds: null, + callbacks: { success: 21, failure: 0 }, + claims: { claimed: 21, recovered: 0 }, + crontab: { run: '*/5 * * * * *', check: '*/10 * * * * *', expire: '0 */5 * * * *' }, + updatedAt: '2026-08-11T10:52:00.000Z', + }, + { + queueId: 3, + namespace: 'partner-sync', + hookUrl: 'https://partner.example.invalid/sync/callback', + concurrency: 2, + waiting: 0, + running: 0, + available: 2, + utilization: 0, + retrying: 0, + deadLetters: 5, + oldestWaitingAt: null, + oldestWaitingAgeSeconds: null, + callbacks: { success: 9, failure: 4 }, + claims: { claimed: 13, recovered: 2 }, + crontab: { run: '* * * * * *', check: '*/10 * * * * *', expire: '0 */5 * * * *' }, + updatedAt: '2026-08-11T10:58:00.000Z', + }, + ], +}; + +const DEMO_DEAD_LETTERS: Record = { + 3: [ + { entryId: 'demo-01', taskId: 'partner-sync-20260811-041', retryCount: 3, failedAt: '2026-08-11T10:49:12.000Z', reason: 'callback_failed' }, + { entryId: 'demo-02', taskId: 'partner-sync-20260811-037', retryCount: 3, failedAt: '2026-08-11T10:41:08.000Z', reason: 'callback_failed' }, + { entryId: 'demo-03', taskId: 'partner-sync-20260811-029', retryCount: 2, failedAt: '2026-08-11T10:25:44.000Z', reason: 'lease_expired' }, + { entryId: 'demo-04', taskId: 'partner-sync-20260811-018', retryCount: 3, failedAt: '2026-08-11T10:04:20.000Z', reason: 'callback_failed' }, + { entryId: 'demo-05', taskId: 'partner-sync-20260811-012', retryCount: 2, failedAt: '2026-08-11T09:53:07.000Z', reason: 'lease_expired' }, + ], +}; + +export function demoDeadLetters(queueId: number, offset: number, limit: number): DemoDeadLetterPage { + const items = DEMO_DEAD_LETTERS[queueId] ?? []; + return { total: items.length, offset, limit, items: items.slice(offset, offset + limit) }; +} diff --git a/admin-dashboard/src/pages/_app.tsx b/admin-dashboard/src/pages/_app.tsx index 936a084..9395a99 100644 --- a/admin-dashboard/src/pages/_app.tsx +++ b/admin-dashboard/src/pages/_app.tsx @@ -11,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 ee4c793..46ca03e 100644 --- a/admin-dashboard/src/pages/_document.tsx +++ b/admin-dashboard/src/pages/_document.tsx @@ -35,7 +35,7 @@ export default class WaitQueueDocument extends Document {