Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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
permissions:
contents: read
pages: read
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: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: admin-dashboard/out

deploy:
name: Deploy static demo
needs: build
if: github.ref == 'refs/heads/master'
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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 负责排队、并发占位和周期检查,真正的任务仍由业务回调服务执行。

Expand All @@ -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、等待年龄与回调/领取计数,并支持注册队列和提交任务;
- 核心服务无任务载荷、图表、消息总线等额外依赖,保持小而明确。

适合构建、导出、媒体处理、批量通知或第三方限流调用。不适合需要任务历史、优先级、复杂工作流或跨节点调度选主的系统。
Expand Down Expand Up @@ -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 顶栏与独立队列详情页;
Expand Down
22 changes: 17 additions & 5 deletions admin-dashboard/README.md
Original file line number Diff line number Diff line change
@@ -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 或状态管理运行时。未来升级到正式包时仍需保留产品级中性主题覆盖

## 页面怎么用

Expand All @@ -19,6 +19,18 @@ waitqueue.js 的轻量实时运维控制台。它直接读取后端队列配置

当前没有任务历史存储,因此页面不会展示吞吐趋势、成功率、平均耗时或任务明细。

## 在线参观(只读)

访问 [https://peterguy326.github.io/waitqueue.js/](https://peterguy326.github.io/waitqueue.js/) 可直接查看控制台。静态演示保留搜索、模块导航、队列切换、死信查看和浅色/深色主题,但不会连接真实 API;注册或更新队列、提交任务、刷新运行态和死信重放均不可用。演示数据只包含合成 ID 与 `example.invalid` 回调地址,不包含真实任务、租户、token 或业务 URL。演示时间统一按 Asia/Shanghai 显示,保证构建产物与浏览器 hydration 一致。

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`,然后运行:
Expand Down Expand Up @@ -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 # 后端地址示例
Expand Down
28 changes: 19 additions & 9 deletions admin-dashboard/next.config.js
Original file line number Diff line number Diff line change
@@ -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*',
},
];
},
}),
};
4 changes: 3 additions & 1 deletion admin-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 63 additions & 19 deletions admin-dashboard/scripts/check-design-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -45,7 +49,6 @@ const requiredTokens = [
'--ui-surface:',
'--ui-foreground:',
'--ui-primary:',
'--ui-ai:',
'--ui-border:',
'--ui-control-border:',
'--ui-font-sans:',
Expand All @@ -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];
Expand All @@ -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');
}
Expand Down Expand Up @@ -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.');
Loading