From 5174ed6821b4c92b13864720f5efea48cd73b2e8 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 4 Jul 2026 12:49:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(console):=20=E9=A7=95=E9=A7=9B=E8=89=99=20?= =?UTF-8?q?dashboard=E2=80=94=E2=80=94/console/dashboard(-data)=20?= =?UTF-8?q?=E8=81=9A=E5=90=88=20KBDB=20dash=5F*=20entries=20(T-cockpit=20?= =?UTF-8?q?=E2=91=A1,=20Arcrun#3=20console=20=E7=B3=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cypher-executor/src/index.ts | 2 + .../src/routes/console-dashboard.ts | 270 ++++++++++++++++++ cypher-executor/src/routes/kbdb-proxy.ts | 2 +- .../3-specs/arcrun/search-console/design.md | 50 ++++ .../3-specs/arcrun/search-console/tasks.md | 27 ++ 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 cypher-executor/src/routes/console-dashboard.ts create mode 100644 system-dev/docs/3-specs/arcrun/search-console/design.md create mode 100644 system-dev/docs/3-specs/arcrun/search-console/tasks.md diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index 1118afe..1ea1737 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -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.5:KBDB 資料層 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; diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts new file mode 100644 index 0000000..39ee541 --- /dev/null +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -0,0 +1,270 @@ +/** + * arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工) + * + * 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數): + * - GET /console/dashboard-data:從 KBDB(走既有 kbdbBase 慣例,同 kbdb-proxy)讀四種 + * entry_type(dash_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 POST,content 是 JSON 字串): + * dash_beat:{"actor":"總管|cloud-worker|progress-guard","event":"start|done","note":"一句"} + * → 每 actor 取最新一筆(base list 已按 created_at DESC,first-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 走 HTTP(kbdbBase,同 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 | null { + if (!e.content) return null; + try { + const v = JSON.parse(e.content); + return v && typeof v === 'object' ? (v as Record) : null; + } catch { + return null; + } +} + +async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise { + 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(); + 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(); + 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(); + 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 ` + + + + +arcrun 駕駛艙 + + + +
+
+
載入中
+
+
+
今日完成度
+
+
+
+
+
今日路線
+
  • 載入中...
+ +
    +
    等你的事
    +
      +
      +
      +
      +
      + + + +`; +} + +// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字) +consoleDashboardRouter.get('/console/dashboard', (c) => c.html(renderDashboardHtml())); diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 7eed2d1..f60db6a 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -29,7 +29,7 @@ export const kbdbProxyRouter = new Hono<{ Bindings: Bindings }>(); * 不沿用 webhook-handlers.ts 的舊 fallback kbdb.finally.click(inkstone 遺留、已死、要 token)。 * KBDB_BASE_URL 可覆蓋(self-hosted fork 指自己的 KBDB)。 */ -function kbdbBase(env: Bindings): { base: string; headers: Record } { +export function kbdbBase(env: Bindings): { base: string; headers: Record } { const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, ''); const headers: Record = { 'Content-Type': 'application/json' }; if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; diff --git a/system-dev/docs/3-specs/arcrun/search-console/design.md b/system-dev/docs/3-specs/arcrun/search-console/design.md new file mode 100644 index 0000000..330a8a7 --- /dev/null +++ b/system-dev/docs/3-specs/arcrun/search-console/design.md @@ -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 打「同帳號」registry(WORKER_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. 駕駛艙 dashboard(T-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:00–22:00 判定); + yellow = 有 blocked 以外的落後標記(非標準 status 值);green = 其餘。 +- `GET /console/dashboard`:手機優先、一屏、兩格(狀態燈+完成度橫條 / 今日路線+等你的事+ + 收件匣數),每 60 秒自動刷新,無互動功能。 + +## 5. 部署(強制) + +**禁 `acr update`**(部署源綁 GitHub codeload,D20 紅線+會蓋掉 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 多租戶/獨立前端 repo/dashboard 互動功能。 diff --git a/system-dev/docs/3-specs/arcrun/search-console/tasks.md b/system-dev/docs/3-specs/arcrun/search-console/tasks.md new file mode 100644 index 0000000..737ca6b --- /dev/null +++ b/system-dev/docs/3-specs/arcrun/search-console/tasks.md @@ -0,0 +1,27 @@ +# search-console tasks + +> 真相源備註:v0 / hardening 完成證據在 Gitea Arcrun#3 comment 串(curl 輸出)。 + +## Phase 1 — console v0(2026-07-02,cloud-worker,commit 981dc25) + +- [x] 1.1 `GET /console` 單檔 HTML+原生 JS(三查詢區+vectorize 唯讀狀態) +- [x] 1.2 部署 leo21c+端到端 curl 驗證(issue comment 證據) + +## Phase 2 — console hardening(2026-07-03,cloud-worker) + +- [x] 2.1 發現①:/kbdb/entries 接 q/search filter+誠實 total(commit 9024f22) +- [x] 2.2 發現②:API Key 錯位 → email+password 登入(console-auth.ts,commit abf2323) +- [ ] 2.3 發現③:開 vectorize——卡 CF token 缺 Vectorize 權限,等 leo 建 index 或給權限 + +## Phase 3 — 駕駛艙 dashboard(T-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 手工注入部署 leo21c+curl 驗證(禁 acr update) +- [x] 3.5 SDD 補件(本目錄,issue #3 授權;Phase 1/2 追認) + +## Phase 4 — v1(未排) + +- [ ] config 真寫入開關 +- [ ] dashboard 寫入端(心跳/任務由 routine 自動打)標準化 skill