feat(console): 駕駛艙 dashboard——/console/dashboard(-data) 聚合 KBDB dash_* entries (T-cockpit ②, Arcrun#3 console 系)

- GET /console/dashboard-data:無需登入唯讀聚合端點(dash_beat/dash_task/dash_wait/inbox,
  租戶=CONSOLE_TENANT),燈號判定寫死在端點(red=blocked/心跳>240分僅台北09-22判定;
  yellow=非標準落後標記;green=其餘)。created_at 實測 epoch 秒(number),解析防禦性兼容字串
- GET /console/dashboard:單檔 HTML 兩格儀表板(狀態燈+心跳+今日完成度橫條 / 今日路線+
  等你的事+收件匣數),手機優先一屏、60s 自動刷新、無互動
- kbdb-proxy.ts:kbdbBase() 改 export 供 dashboard 共用(同一 KBDB 連線慣例,不重複實作)
- SDD 補件:docs/3-specs/arcrun/search-console/(issue #3 授權;位於既有 arcrun/ 白名單下)

部署:手工注入法(mistakes #23,禁 acr update)deploy leo21c,version 4252c321-e15b-494c-ab97-29f82eefa565

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-04 12:49:59 +08:00
parent 05333b8fe6
commit 5174ed6821
5 changed files with 350 additions and 1 deletions
+2
View File
@@ -23,6 +23,7 @@ import { initSeedRouter } from './routes/init-seed';
import { kbdbProxyRouter } from './routes/kbdb-proxy';
import { consoleRouter } from './routes/console';
import { consoleAuthRouter } from './routes/console-auth';
import { consoleDashboardRouter } from './routes/console-dashboard';
const app = new Hono<{ Bindings: Bindings }>();
@@ -54,6 +55,7 @@ app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為
app.route('/', kbdbProxyRouter); // kbdb-base 9.5KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶)
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
@@ -0,0 +1,270 @@
/**
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工)
*
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* - GET /console/dashboard-data:從 KBDB(走既有 kbdbBase 慣例,同 kbdb-proxy)讀四種
* entry_typedash_beat / dash_task / dash_wait / inbox,租戶 = CONSOLE_TENANT,同
* console-auth 的固定租戶模型)並聚合成一包 JSON。
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),手機優先、一屏、兩格,
* 每 60 秒自動 fetch dashboard-data 刷新。無互動功能,就是儀表板。
*
* 資料契約(寫入方=總管/cloud-worker/progress-guard 往 /kbdb/entries POSTcontent 是 JSON 字串):
* dash_beat{"actor":"總管|cloud-worker|progress-guard","event":"start|done","note":"一句"}
* → 每 actor 取最新一筆(base list 已按 created_at DESCfirst-seen 即最新)。
* dash_task{"title","status":"done|doing|todo|blocked","order":1,"scope":"today|week"}
* → 以 title 為 key 取 created 最新(後寫蓋前寫)。
* dash_wait{"title","status":"open|closed"} → 同 title 取最新,只回 open。
* inbox {"text","status":"new|done"} → 計數未處理(status !== 'done')。
*
* 燈號判定(寫死在端點,頁面只渲染):
* red = 任一 task status=blocked,或最新心跳距今 > 240 分(僅台北時間 09:00-22:00 判定;
* 窗外心跳老是正常的睡眠狀態,不判)。
* yellow = 無 red 條件,但有 blocked 以外的落後標記(task status 不在 done/doing/todo/blocked
* 四個標準值內,如 late/behind——寫入方標了非標準狀態=落後訊號)。
* green = 其餘。
*
* 薄殼定位:這是「聚合端點」(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯,
* 判定全在此端點;讀 KBDB 走 HTTPkbdbBase,同 proxy 慣例),不新增 binding、不碰 D1/SQL。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
const STALE_MINUTES = 240;
const JUDGE_START_HOUR = 9; // 台北時間,含
const JUDGE_END_HOUR = 22; // 台北時間,不含
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
interface KbdbEntry {
id: string;
content: string | null;
entry_type: string;
created_at: string | number; // leo21c 實測是 epoch 秒(number);防禦性也吃 sqlite/ISO 字串
}
/** created_at 實測(leo21c KBDB)=epoch 秒數 number;防禦性同時吃 epoch 毫秒與
* 'YYYY-MM-DD HH:MM:SS'UTC/ ISO 字串(別的部署可能不同 schema 版本)。 */
function parseCreatedAtMs(s: string | number | null | undefined): number | null {
if (s === null || s === undefined || s === '') return null;
if (typeof s === 'number') return s < 1e12 ? s * 1000 : s; // 秒 vs 毫秒
if (/^\d+$/.test(s)) {
const n = Number(s);
return n < 1e12 ? n * 1000 : n;
}
const iso = /T/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
const ms = Date.parse(iso);
return Number.isNaN(ms) ? null : ms;
}
function parseJsonContent(e: KbdbEntry): Record<string, unknown> | null {
if (!e.content) return null;
try {
const v = JSON.parse(e.content);
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
} catch {
return null;
}
}
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
if (!res.ok) return [];
const data = (await res.json()) as { entries?: KbdbEntry[] };
return data.entries ?? [];
}
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const now = Date.now();
const [beatEntries, taskEntries, waitEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant, 'dash_beat', 100),
fetchEntries(c.env, tenant, 'dash_task', 200),
fetchEntries(c.env, tenant, 'dash_wait', 100),
fetchEntries(c.env, tenant, 'inbox', 200),
]);
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
const seenActors = new Set<string>();
for (const e of beatEntries) {
const j = parseJsonContent(e);
const actor = typeof j?.actor === 'string' ? j.actor : null;
if (!actor || seenActors.has(actor)) continue;
seenActors.add(actor);
const ms = parseCreatedAtMs(e.created_at);
beats.push({
actor,
event: typeof j?.event === 'string' ? (j.event as string) : '',
note: typeof j?.note === 'string' ? (j.note as string) : '',
at: e.created_at,
ago_minutes: ms === null ? -1 : Math.max(0, Math.round((now - ms) / 60000)),
});
}
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
// dash_task:同 title 取最新(後寫蓋前寫)
const taskByTitle = new Map<string, { title: string; status: string; order: number; scope: string }>();
for (const e of taskEntries) {
const j = parseJsonContent(e);
const title = typeof j?.title === 'string' ? (j.title as string) : null;
if (!title || taskByTitle.has(title)) continue;
taskByTitle.set(title, {
title,
status: typeof j?.status === 'string' ? (j.status as string) : 'todo',
order: typeof j?.order === 'number' ? (j.order as number) : 999,
scope: j?.scope === 'week' ? 'week' : 'today',
});
}
const tasks = [...taskByTitle.values()].sort((a, b) =>
a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
);
// dash_wait:同 title 取最新,只回 open
const waitByTitle = new Map<string, string>();
for (const e of waitEntries) {
const j = parseJsonContent(e);
const title = typeof j?.title === 'string' ? (j.title as string) : null;
if (!title || waitByTitle.has(title)) continue;
waitByTitle.set(title, typeof j?.status === 'string' ? (j.status as string) : 'open');
}
const waiting = [...waitByTitle.entries()].filter(([, s]) => s === 'open').map(([title]) => ({ title }));
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
const inboxNew = inboxEntries.reduce((n, e) => {
const j = parseJsonContent(e);
return j && j.status !== 'done' ? n + 1 : n;
}, 0);
// 燈號
const hasBlocked = tasks.some((t) => t.status === 'blocked');
const hasLagMark = tasks.some((t) => !STANDARD_TASK_STATUS.has(t.status));
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
const light: 'green' | 'yellow' | 'red' =
hasBlocked || (inJudgeWindow && beatStale) ? 'red' : hasLagMark ? 'yellow' : 'green';
const todayTasks = tasks.filter((t) => t.scope === 'today');
return c.json({
light,
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
beats,
tasks,
today_done: todayTasks.filter((t) => t.status === 'done').length,
today_total: todayTasks.length,
waiting,
inbox_new: inboxNew,
generated_at: new Date(now).toISOString(),
});
});
function renderDashboardHtml(): string {
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arcrun 駕駛艙</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115; color: #e6e6e6; }
main { padding: 14px; display: grid; gap: 14px; max-width: 560px; margin: 0 auto; }
.card { background: #161922; border: 1px solid #262a33; border-radius: 12px; padding: 16px; }
.light-row { display: flex; align-items: center; gap: 12px; }
.light-emoji { font-size: 44px; line-height: 1; }
.light-word { font-size: 20px; font-weight: 700; }
.beat { color: #9aa0aa; font-size: 13px; margin-top: 8px; }
.bar-wrap { margin-top: 12px; }
.bar-label { font-size: 12px; color: #9aa0aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
.bar { height: 10px; background: #0f1115; border: 1px solid #262a33; border-radius: 999px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: #3a5bfd; border-radius: 999px; transition: width .4s; }
.subhead { font-size: 12px; color: #9aa0aa; margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.subhead:first-child { margin-top: 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
li { font-size: 14px; line-height: 1.5; }
.muted { color: #6b7280; font-size: 13px; }
.inbox { margin-top: 12px; font-size: 13px; color: #9aa0aa; }
.err { color: #f06565; font-size: 13px; }
.stamp { text-align: center; color: #4b5563; font-size: 11px; }
</style>
</head>
<body>
<main>
<div class="card" id="card-light">
<div class="light-row"><span class="light-emoji" id="light-emoji">⏳</span><span class="light-word" id="light-word">載入中</span></div>
<div class="beat" id="beat-line"></div>
<div class="bar-wrap">
<div class="bar-label"><span>今日完成度</span><span id="bar-num"></span></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
</div>
</div>
<div class="card">
<div class="subhead">今日路線</div>
<ul id="today-list"><li class="muted">載入中...</li></ul>
<div class="subhead" id="week-head" style="display:none">本週</div>
<ul id="week-list"></ul>
<div class="subhead" style="margin-top:14px">等你的事</div>
<ul id="wait-list"></ul>
<div class="inbox" id="inbox-line"></div>
</div>
<div class="stamp" id="stamp"></div>
</main>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const LIGHT = { green: ['🟢', '系統運轉中'], yellow: ['🟡', '落後趕工中'], red: ['🔴', '卡住'] };
const STATUS_EMOJI = { done: '✅', doing: '🔄', todo: '⬜', blocked: '⛔' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function taskLine(t) {
return '<li>' + (STATUS_EMOJI[t.status] || '⚠️') + ' ' + esc(t.title) + '</li>';
}
async function load() {
try {
const res = await fetch('/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const [emoji, word] = LIGHT[d.light] || ['⚪', d.light];
$('light-emoji').textContent = emoji;
$('light-word').textContent = word;
$('beat-line').textContent = d.last_beat
? '最後心跳 ' + d.last_beat.actor + ' ' + d.last_beat.ago_minutes + ' 分鐘前'
: '尚無心跳';
const done = d.today_done || 0, total = d.today_total || 0;
$('bar-num').textContent = done + ' / ' + total;
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="muted">今日無排定項目</li>';
$('week-head').style.display = week.length ? '' : 'none';
$('week-list').innerHTML = week.map(taskLine).join('');
$('wait-list').innerHTML = (d.waiting && d.waiting.length)
? d.waiting.map((w) => '<li>🙋 ' + esc(w.title) + '</li>').join('')
: '<li class="muted">無,你不用做任何事</li>';
$('inbox-line').textContent = '📥 收件匣未處理:' + (d.inbox_new || 0) + ' 件';
$('stamp').textContent = '更新於 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
} catch (e) {
$('light-emoji').textContent = '⚪';
$('light-word').textContent = '讀不到狀態';
$('beat-line').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
}
}
load();
setInterval(load, 60000);
})();
</script>
</body>
</html>
`;
}
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
consoleDashboardRouter.get('/console/dashboard', (c) => c.html(renderDashboardHtml()));
+1 -1
View File
@@ -29,7 +29,7 @@ export const kbdbProxyRouter = new Hono<{ Bindings: Bindings }>();
* 不沿用 webhook-handlers.ts 的舊 fallback kbdb.finally.clickinkstone 遺留、已死、要 token)。
* KBDB_BASE_URL 可覆蓋(self-hosted fork 指自己的 KBDB)。
*/
function kbdbBase(env: Bindings): { base: string; headers: Record<string, string> } {
export function kbdbBase(env: Bindings): { base: string; headers: Record<string, string> } {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
@@ -0,0 +1,50 @@
# search-console — arcrun 自帶搜尋/控制台頁(Gitea Arcrun#3 授權新建)
> 授權:Gitea `Leo/Arcrun#3`leo 2026-07-02 拍板「本 issue 即 leo 授權」)。
> 位於既有白名單 `docs/3-specs/arcrun/` 之下(pre-write-guard 4.3 已涵蓋,無需改 hook)。
> 本 SDD 是追認式補件:v0 與 hardening 已由 cloud-worker 實作(無 docs 環境無法落地 SDD),
> 詳細實作記錄與客觀證據在 issue #3 comment 串(真相源),此處只固定設計要點。
## 1. 定位
裝了 arcrun(含 KBDB)就有的一頁式控制台:知識庫 / workflows / components & recipes 搜尋
config 唯讀狀態(vectorize)+ 駕駛艙 dashboard。**wow factor = 安裝完第一眼體驗**。
## 2. 設計鐵律(rule 07 薄殼)
- 落點:`cypher-executor/src/routes/console*.ts`,單檔 HTML + 原生 JS,無框架、無 build step。
- 頁面零業務邏輯,只 fetch 既有 API;缺端點 → 補 API 或發 issue,不准在 UI 拼裝。
- 聚合類能力(如 dashboard-data 的燈號判定)長在 API 端點(能力一次),頁面純渲染。
- components 打「同帳號」registryWORKER_SUBDOMAIN 現算),禁硬編清單(parts.ts stale 教訓)。
- 讀 KBDB 一律走 `kbdbBase()` HTTP(同 kbdb-proxy 慣例),不新增 binding、不直碰 D1/SQL。
## 3. 認證模型
- console 查詢頁:簡單 email+password 登入(`console-auth.ts`,單一管理員帳密存 SESSIONS_KV
首次設定制、不可覆蓋、reset 需舊密碼)。登入只擋外人看頁面;查詢用固定租戶字串
`CONSOLE_TENANT`(預設 `leo`self-hosted 明碼 namespace,非註冊制 key)。
- dashboard(駕駛艙):**無需登入**——唯讀聚合、不吐機敏值(燈號/任務標題/計數)。
## 4. 駕駛艙 dashboardT-cockpit ②,2026-07-04
- `GET /console/dashboard-data`:聚合 KBDB 四種 entry_type(租戶 = CONSOLE_TENANT):
- `dash_beat` `{"actor","event":"start|done","note"}` → 每 actor 最新一筆。
- `dash_task` `{"title","status":"done|doing|todo|blocked","order","scope":"today|week"}`
→ 同 title 後寫蓋前寫(created 最新)。
- `dash_wait` `{"title","status":"open|closed"}` → 同 title 取最新,只回 open。
- `inbox` `{"text","status":"new|done"}` → 未處理計數(status !== done)。
- 燈號:red = 任一 blocked 或最新心跳 >240 分(僅台北 09:0022:00 判定);
yellow = 有 blocked 以外的落後標記(非標準 status 值);green = 其餘。
- `GET /console/dashboard`:手機優先、一屏、兩格(狀態燈+完成度橫條 / 今日路線+等你的事+
收件匣數),每 60 秒自動刷新,無互動功能。
## 5. 部署(強制)
**禁 `acr update`**(部署源綁 GitHub codeloadD20 紅線+會蓋掉 Gitea 改動=假綠,mistakes #23)。
用手工複刻 `injectWranglerConfig` 注入法:CF API 查真實資源 id → 暫寫 wrangler.toml →
`wrangler deploy --dry-run` 核對 binding 與線上一致 → 真部署 → 立即 restore toml。
全程 `CLOUDFLARE_ACCOUNT_ID=51a01bfa…`leo21c)蓋掉 repo `.env` 的官方 id。
## 6. 明確不做(v1 再議)
真寫入 config 開關/漂亮設計/官方 SaaS 多租戶/獨立前端 repodashboard 互動功能。
@@ -0,0 +1,27 @@
# search-console tasks
> 真相源備註:v0 / hardening 完成證據在 Gitea Arcrun#3 comment 串(curl 輸出)。
## Phase 1 — console v02026-07-02cloud-workercommit 981dc25
- [x] 1.1 `GET /console` 單檔 HTML+原生 JS(三查詢區+vectorize 唯讀狀態)
- [x] 1.2 部署 leo21c+端到端 curl 驗證(issue comment 證據)
## Phase 2 — console hardening2026-07-03cloud-worker
- [x] 2.1 發現①:/kbdb/entries 接 q/search filter+誠實 totalcommit 9024f22
- [x] 2.2 發現②:API Key 錯位 → email+password 登入(console-auth.tscommit abf2323
- [ ] 2.3 發現③:開 vectorize——卡 CF token 缺 Vectorize 權限,等 leo 建 index 或給權限
## Phase 3 — 駕駛艙 dashboardT-cockpit ②,2026-07-04,總管派工 subagent
- [x] 3.1 `GET /console/dashboard-data` 聚合端點(dash_beat/dash_task/dash_wait/inbox+燈號判定)
- [x] 3.2 `GET /console/dashboard` 儀表板頁(兩格、手機優先、60s 自動刷新、無需登入)
- [x] 3.3 灌初始資料(dash_task×5 / dash_wait×1 / dash_beat×1,經 /kbdb/entries POST
- [x] 3.4 手工注入部署 leo21ccurl 驗證(禁 acr update
- [x] 3.5 SDD 補件(本目錄,issue #3 授權;Phase 1/2 追認)
## Phase 4 — v1(未排)
- [ ] config 真寫入開關
- [ ] dashboard 寫入端(心跳/任務由 routine 自動打)標準化 skill