fix(console): 駕駛艙首頁改讀 live 一手資料,stale 殘骸誠實標示
leo 抱怨「等你的事」錯了好幾天、首頁總覽對不上實際進度。診斷結論: - dash_wait 沒有活的維護管線(最後寫入 07-04;等leo清單#11 已於 07-05 銷案=誤判,dashboard 卻繼續掛著)——真相源其實是 InkStoneCo sprint 檔 「## 等 leo 清單」(progress-guard 每日核實維護) - dash_task 的 scope:today 沒有日期,07-04 的路線到 07-07 還被當今天的 - 燈號吃到 stale 任務;系統狀況/總覽無 live 信號 修法: - 等你的事:首選 Gitea sprint 等leo清單(合併最新兩檔——07b 開了、🔴 仍 掛 07a;需 GITEA_TOKEN secret);讀不到 fallback dash_wait 必標 age+stale - 今日路線:台北日曆日判 is_today,非今日寫入降級「最後路線(N 天前)」 - 燈號只吃今日寫入任務+心跳+KBDB /health,附 light_reason - 新增系統狀況(KBDB /health、embed enabled:false 誠實顯示、graph /triplets/stats、workflow 數)與總庫規模(entries/wiki_card/triplets)全 live - 聚合判定抽純函式 lib/console-dashboard-model.ts,vitest 15 案例 (含真實 sprint 檔形態:劃掉列/✅ 急迫欄/R1 字母編號/🔴 排序) 驗證:tsc --noEmit 0;vitest 65/66(1 失敗為 main 既有 executor.test.ts, stash 復原後同樣失敗,非本次引入);parser 對 live sprint-2026-07a/07b dry-run 撈出 🔴#13 等 9 項 open、清單維護於 31 分鐘前。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* console 駕駛艙 dashboard 的聚合純函式層(2026-07-07 fix/console-dashboard-live-data)
|
||||||
|
*
|
||||||
|
* 為什麼獨立成 lib:routes/console-dashboard.ts 原本把「解析 + 判定」焊在 route 裡,
|
||||||
|
* 無法單測;本檔只放**純函式**(無 fetch、無 env),route 負責 IO、這裡負責判定,
|
||||||
|
* vitest 可直接餵資料驗證(誠實限制 mindset §7:修 stale bug 要有客觀證據)。
|
||||||
|
*
|
||||||
|
* 本次修的三個 stale 根因(leo 抱怨「等你的事錯了好幾天」的診斷結論):
|
||||||
|
* 1. dash_wait 沒有活的維護管線(最後寫入 2026-07-04;等leo清單#11 已於 07-05 銷案=誤判,
|
||||||
|
* dashboard 卻繼續掛著它)——「等 leo」的真相源其實是 InkStoneCo sprint 檔的
|
||||||
|
* 「## 等 leo 清單」表格(progress-guard 每日核實維護)。→ 改讀 Gitea sprint 檔,
|
||||||
|
* 讀不到才 fallback dash_wait 且必標 age,不假裝是今天的。
|
||||||
|
* 2. dash_task 的 scope:"today" 沒有日期概念——07-04 寫入的「今日路線」到 07-07 還被
|
||||||
|
* 當成今天的。→ 以台北日曆日判定 is_today,非今日寫入的任務誠實降級為「最後路線(N 天前)」。
|
||||||
|
* 3. 燈號 hasBlocked/hasLagMark 吃到 stale 任務——幾天前的 blocked 會讓今天亮紅燈。
|
||||||
|
* → 燈號只吃「今日寫入」的任務 + 心跳(心跳 dash_beat 是唯一有活管線的 dash_* 資料)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export 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 版本)。 */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 台北(UTC+8)日曆日 key,用來判定「這筆 dash_task 是不是今天寫的」。 */
|
||||||
|
export function taipeiDayKey(ms: number): string {
|
||||||
|
return new Date(ms + 8 * 3600 * 1000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agoMinutes(nowMs: number, ms: number | null): number {
|
||||||
|
return ms === null ? -1 : Math.max(0, Math.round((nowMs - ms) / 60000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 今日路線(dash_task)───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DashTask {
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
order: number;
|
||||||
|
scope: 'today' | 'week';
|
||||||
|
at_ms: number | null;
|
||||||
|
age_minutes: number;
|
||||||
|
/** 台北日曆日判定:這筆的最新寫入是不是「今天」寫的 */
|
||||||
|
is_today_write: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteModel {
|
||||||
|
tasks: DashTask[];
|
||||||
|
/** 最新一筆 dash_task 寫入距今分鐘數;-1 = 全庫沒有 dash_task */
|
||||||
|
updated_ago_minutes: number;
|
||||||
|
/** 最新寫入是否發生在台北「今天」——false = 整包路線是殘資料 */
|
||||||
|
is_today: boolean;
|
||||||
|
today_done: number;
|
||||||
|
today_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dash_task 聚合:同 title 取最新(list 已 created_at DESC → first-seen 即最新),
|
||||||
|
* 但「今日完成 n/m」只數今天(台北)寫入的任務——幾天前的殘路線不假裝是今天的。 */
|
||||||
|
export function buildRouteModel(entries: KbdbEntry[], nowMs: number): RouteModel {
|
||||||
|
const todayKey = taipeiDayKey(nowMs);
|
||||||
|
const byTitle = new Map<string, DashTask>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const j = parseJsonContent(e);
|
||||||
|
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
||||||
|
if (!title || byTitle.has(title)) continue;
|
||||||
|
const ms = parseCreatedAtMs(e.created_at);
|
||||||
|
byTitle.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',
|
||||||
|
at_ms: ms,
|
||||||
|
age_minutes: agoMinutes(nowMs, ms),
|
||||||
|
is_today_write: ms !== null && taipeiDayKey(ms) === todayKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tasks = [...byTitle.values()].sort((a, b) =>
|
||||||
|
a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
|
||||||
|
);
|
||||||
|
const newestMs = tasks.reduce<number | null>((m, t) => (t.at_ms !== null && (m === null || t.at_ms > m) ? t.at_ms : m), null);
|
||||||
|
const isToday = newestMs !== null && taipeiDayKey(newestMs) === todayKey;
|
||||||
|
const todayTasks = tasks.filter((t) => t.scope === 'today' && t.is_today_write);
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
updated_ago_minutes: agoMinutes(nowMs, newestMs),
|
||||||
|
is_today: isToday,
|
||||||
|
today_done: todayTasks.filter((t) => t.status === 'done').length,
|
||||||
|
today_total: todayTasks.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 等你的事 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface WaitingItem {
|
||||||
|
title: string;
|
||||||
|
/** Gitea sprint 來源才有:等leo清單編號("13"、"R1"——07b 起用字母前綴)與急迫 emoji */
|
||||||
|
id?: string;
|
||||||
|
urgency?: string;
|
||||||
|
/** 合併多個 sprint 檔時標出處(如 "sprint-2026-07a.md") */
|
||||||
|
sprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitingModel {
|
||||||
|
items: WaitingItem[];
|
||||||
|
/** 'gitea_sprint' = 讀到活資料源;'kbdb_dash_wait' = fallback(必標 age);'none' = 管線未接 */
|
||||||
|
source: 'gitea_sprint' | 'kbdb_dash_wait' | 'none';
|
||||||
|
/** 資料源上次維護距今分鐘;-1 = 不明 */
|
||||||
|
updated_ago_minutes: number;
|
||||||
|
/** true = 這包資料超過閾值沒人維護,頁面必須明示「可能過時」 */
|
||||||
|
stale: boolean;
|
||||||
|
/** gitea_sprint 來源:實際讀了哪些 sprint 檔(新→舊) */
|
||||||
|
sprint_files?: string[];
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const URGENCY_EMOJI = /(🔴|🟡|🟢|⚪)/u;
|
||||||
|
/** 急迫欄含這些字樣=已結案,不再是「等你的事」 */
|
||||||
|
const CLOSED_MARKERS = /(✅|已完成|已解|銷案|已銷)/u;
|
||||||
|
|
||||||
|
/** 從 sprint md 撈「## 等 leo 清單」表格(含「(RAG 線)」等帶註記變體),回傳仍 open 的項目。
|
||||||
|
* 規則(對照 sprint-2026-07a/07b.md 實際維護慣例):
|
||||||
|
* - 編號欄吃英數(07a 純數字 "13";07b 起 "R1" 字母前綴)
|
||||||
|
* - 事項欄以 ~~ 開頭(整項劃掉)→ 已結案,跳過
|
||||||
|
* - 急迫欄含 ✅/已完成/已解/銷案 → 已結案,跳過
|
||||||
|
* 解析失敗(找不到段落/表格)→ 回 null,caller 誠實 fallback,不硬湊。排序交給 sortWaitingItems。 */
|
||||||
|
export function parseSprintWaitingTable(md: string, sprintFile?: string): WaitingItem[] | null {
|
||||||
|
const secIdx = md.search(/^##\s*等\s*leo\s*清單/mu);
|
||||||
|
if (secIdx < 0) return null;
|
||||||
|
const section = md.slice(secIdx);
|
||||||
|
const lines = section.split('\n');
|
||||||
|
const items: WaitingItem[] = [];
|
||||||
|
let sawTable = false;
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
if (/^#{2,3}\s/.test(line) && !/^##\s*等/.test(line)) break; // 下一個段落,停
|
||||||
|
const m = line.match(/^\|\s*([A-Za-z]?\d+)\s*\|(.*)\|(.*)\|\s*$/u);
|
||||||
|
if (!m) continue;
|
||||||
|
sawTable = true;
|
||||||
|
const id = m[1];
|
||||||
|
const item = m[2].trim();
|
||||||
|
const urgency = m[3].trim();
|
||||||
|
if (item.startsWith('~~')) continue;
|
||||||
|
if (CLOSED_MARKERS.test(urgency)) continue;
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
urgency: urgency.match(URGENCY_EMOJI)?.[1] ?? '',
|
||||||
|
title: cleanWaitingTitle(item),
|
||||||
|
sprint: sprintFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!sawTable) return null;
|
||||||
|
return sortWaitingItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 🔴 > 🟡 > 其他;同急迫維持傳入順序(stable sort,現役 sprint 的項目先傳先顯示)。 */
|
||||||
|
export function sortWaitingItems(items: WaitingItem[]): WaitingItem[] {
|
||||||
|
const rank = (u?: string) => (u === '🔴' ? 0 : u === '🟡' ? 1 : 2);
|
||||||
|
return items
|
||||||
|
.map((it, i) => ({ it, i }))
|
||||||
|
.sort((a, b) => rank(a.it.urgency) - rank(b.it.urgency) || a.i - b.i)
|
||||||
|
.map((x) => x.it);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事項欄清洗:去 markdown 粗體/劃掉/行內 code、去開頭急迫 emoji(急迫欄已有,別重複)、
|
||||||
|
* 截前 80 字(cell 動輒數百字,儀表板只要標題),截斷若留下未閉合的「(」順手剪掉。 */
|
||||||
|
export function cleanWaitingTitle(raw: string): string {
|
||||||
|
let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
|
||||||
|
s = s.replace(/^(?:🔴|🟡|🟢|⚪)\s*/u, '');
|
||||||
|
// 常見格式「**標題**(時間 說明):長解釋…」→ 取第一個「:」前;太短則不切
|
||||||
|
const colon = s.indexOf(':');
|
||||||
|
if (colon >= 12) s = s.slice(0, colon);
|
||||||
|
if (s.length > 80) s = `${s.slice(0, 79)}…`;
|
||||||
|
// 截斷可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉
|
||||||
|
let depth = 0;
|
||||||
|
let lastOpen = -1;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
if (s[i] === '(') {
|
||||||
|
if (depth === 0) lastOpen = i;
|
||||||
|
depth++;
|
||||||
|
} else if (s[i] === ')') {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth > 0 && lastOpen >= 0) s = s.slice(0, lastOpen).trim();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sprint 檔命名慣例 sprint-YYYY-MM<letter>.md,字典序即時間序。
|
||||||
|
* 取最新 n 個(新→舊):現役 sprint 換檔後,前一個 sprint 的等leo清單常還有未銷案項
|
||||||
|
* (實例:07b 開了、🔴 mira 憑證外洩仍掛在 07a)——只讀最新一檔會漏掉真正在等的事。 */
|
||||||
|
export function pickLatestSprintFiles(names: string[], n = 2): string[] {
|
||||||
|
return names
|
||||||
|
.filter((name) => /^sprint-.*\.md$/.test(name))
|
||||||
|
.sort()
|
||||||
|
.slice(-n)
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fallback:dash_wait 同 title 取最新、只留 open——但每項帶 age、整包帶 stale 判定。
|
||||||
|
* 這條路只在 Gitea 讀不到時走;它的資料 2026-07-04 之後就沒人維護,所以 stale 標記是義務。 */
|
||||||
|
export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): WaitingModel {
|
||||||
|
const byTitle = new Map<string, { status: string; at_ms: number | null }>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const j = parseJsonContent(e);
|
||||||
|
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
||||||
|
if (!title || byTitle.has(title)) continue;
|
||||||
|
byTitle.set(title, {
|
||||||
|
status: typeof j?.status === 'string' ? (j.status as string) : 'open',
|
||||||
|
at_ms: parseCreatedAtMs(e.created_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const open = [...byTitle.entries()].filter(([, v]) => v.status === 'open');
|
||||||
|
const newestMs = [...byTitle.values()].reduce<number | null>(
|
||||||
|
(m, v) => (v.at_ms !== null && (m === null || v.at_ms > m) ? v.at_ms : m),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const ago = agoMinutes(nowMs, newestMs);
|
||||||
|
return {
|
||||||
|
items: open.map(([title]) => ({ title })),
|
||||||
|
source: byTitle.size ? 'kbdb_dash_wait' : 'none',
|
||||||
|
updated_ago_minutes: ago,
|
||||||
|
stale: ago < 0 || ago > 24 * 60,
|
||||||
|
note: byTitle.size
|
||||||
|
? undefined
|
||||||
|
: '管線未接:dash_wait 無資料、Gitea sprint 讀取未設定(GITEA_TOKEN)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人話 age:分鐘 → 「3 分鐘前 / 5 小時前 / 3 天前」。-1 → 「時間不明」。 */
|
||||||
|
export function humanAge(minutes: number): string {
|
||||||
|
if (minutes < 0) return '時間不明';
|
||||||
|
if (minutes < 60) return `${minutes} 分鐘前`;
|
||||||
|
if (minutes < 48 * 60) return `${Math.round(minutes / 60)} 小時前`;
|
||||||
|
return `${Math.round(minutes / (24 * 60))} 天前`;
|
||||||
|
}
|
||||||
@@ -1,35 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工)
|
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||||||
|
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||||||
*
|
*
|
||||||
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||||
* - GET /console/dashboard-data:從 KBDB(走既有 kbdbBase 慣例,同 kbdb-proxy)讀四種
|
* - GET /console/dashboard-data:聚合 JSON。
|
||||||
* entry_type(dash_beat / dash_task / dash_wait / inbox,租戶 = CONSOLE_TENANT,同
|
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
|
||||||
* console-auth 的固定租戶模型)並聚合成一包 JSON。
|
|
||||||
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),手機優先、一屏、兩格,
|
|
||||||
* 每 60 秒自動 fetch dashboard-data 刷新。無互動功能,就是儀表板。
|
|
||||||
*
|
*
|
||||||
* 資料契約(寫入方=總管/cloud-worker/progress-guard 往 /kbdb/entries POST,content 是 JSON 字串):
|
* ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
|
||||||
* dash_beat:{"actor":"總管|cloud-worker|progress-guard","event":"start|done","note":"一句"}
|
*
|
||||||
* → 每 actor 取最新一筆(base list 已按 created_at DESC,first-seen 即最新)。
|
* 資料源診斷(leo 抱怨「等你的事錯了好幾天」的根因):
|
||||||
* dash_task:{"title","status":"done|doing|todo|blocked","order":1,"scope":"today|week"}
|
* - dash_wait(等你的事舊資料源)最後寫入 2026-07-04,**沒有活的維護管線**——等leo清單#11
|
||||||
* → 以 title 為 key 取 created 最新(後寫蓋前寫)。
|
* 已於 07-05 銷案(誤判),dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的
|
||||||
* dash_wait:{"title","status":"open|closed"} → 同 title 取最新,只回 open。
|
* 「## 等 leo 清單」表格(progress-guard routine 每日核實維護)。
|
||||||
* inbox :{"text","status":"new|done"} → 計數未處理(status !== 'done')。
|
* - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。
|
||||||
|
* - dash_beat 是唯一有活管線的 dash_*(progress-guard/cloud-worker/watchdog 每日寫入)。
|
||||||
|
*
|
||||||
|
* 整修後的資料源:
|
||||||
|
* 等你的事 → 首選 Gitea sprint 檔等leo清單(需 GITEA_BASE_URL var + GITEA_TOKEN secret;
|
||||||
|
* 進程內 fetch Gitea API,非 GitHub、無 D20 疑慮);讀不到 → fallback dash_wait
|
||||||
|
* 但必標 age + stale 警示;連 dash_wait 都沒有 → 誠實顯示「管線未接」。
|
||||||
|
* 今日路線 → dash_task,但以台北日曆日判 is_today;非今日寫入=降級顯示「最後路線(N 天前)」,
|
||||||
|
* 不假裝是今天的。今日無寫入時明講管線缺口(sprint 任務板→dashboard 無自動投影)。
|
||||||
|
* 系統狀況 → live 健康信號:KBDB /health、/embed/backfill/status(enabled:false 誠實顯示)、
|
||||||
|
* kbdb-graph-plugin /triplets/stats、workflow 總數(KBDB entry_type=workflow)。
|
||||||
|
* 總庫規模 → KBDB entries 總數/wiki_card 數/triplets 數,全部 live API 一手拉。
|
||||||
*
|
*
|
||||||
* 燈號判定(寫死在端點,頁面只渲染):
|
* 燈號判定(寫死在端點,頁面只渲染):
|
||||||
* red = 任一 task status=blocked,或最新心跳距今 > 240 分(僅台北時間 09:00-22:00 判定;
|
* red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定),
|
||||||
* 窗外心跳老是正常的睡眠狀態,不判)。
|
* 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。
|
||||||
* yellow = 無 red 條件,但有 blocked 以外的落後標記(task status 不在 done/doing/todo/blocked
|
* yellow = 無 red 條件,但今日任務有非標準 status(late/behind 等落後標記)。
|
||||||
* 四個標準值內,如 late/behind——寫入方標了非標準狀態=落後訊號)。
|
|
||||||
* green = 其餘。
|
* green = 其餘。
|
||||||
*
|
*
|
||||||
* 薄殼定位:這是「聚合端點」(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯,
|
* 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在
|
||||||
* 判定全在此端點;讀 KBDB 走 HTTP(kbdbBase,同 proxy 慣例),不新增 binding、不碰 D1/SQL。
|
* lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 binding。
|
||||||
*/
|
*/
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { kbdbBase } from './kbdb-proxy';
|
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||||
import { validateConsoleSession } from './console-auth';
|
import { validateConsoleSession } from './console-auth';
|
||||||
|
import {
|
||||||
|
type KbdbEntry,
|
||||||
|
type WaitingItem,
|
||||||
|
type WaitingModel,
|
||||||
|
parseCreatedAtMs,
|
||||||
|
parseJsonContent,
|
||||||
|
agoMinutes,
|
||||||
|
buildRouteModel,
|
||||||
|
buildWaitingFallback,
|
||||||
|
parseSprintWaitingTable,
|
||||||
|
pickLatestSprintFiles,
|
||||||
|
sortWaitingItems,
|
||||||
|
} from '../lib/console-dashboard-model';
|
||||||
|
|
||||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -38,59 +59,124 @@ const JUDGE_START_HOUR = 9; // 台北時間,含
|
|||||||
const JUDGE_END_HOUR = 22; // 台北時間,不含
|
const JUDGE_END_HOUR = 22; // 台北時間,不含
|
||||||
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
|
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
|
||||||
|
|
||||||
interface KbdbEntry {
|
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
|
||||||
id: string;
|
const { base, headers } = kbdbBase(env);
|
||||||
content: string | null;
|
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
|
||||||
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 {
|
try {
|
||||||
const v = JSON.parse(e.content);
|
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||||
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as { entries?: KbdbEntry[] };
|
||||||
|
return data.entries ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */
|
||||||
|
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, headers ? { headers } : undefined);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
|
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||||
|
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||||
const { base, headers } = kbdbBase(env);
|
const { base, headers } = kbdbBase(env);
|
||||||
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
|
const params = new URLSearchParams({ ...filters, limit: '1' });
|
||||||
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
|
||||||
if (!res.ok) return [];
|
return data && typeof data.total === 'number' ? data.total : null;
|
||||||
const data = (await res.json()) as { entries?: KbdbEntry[] };
|
}
|
||||||
return data.entries ?? [];
|
|
||||||
|
/**
|
||||||
|
* 「等你的事」活資料源:InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)。
|
||||||
|
* 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序:
|
||||||
|
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項,例:07b 開了、
|
||||||
|
* 🔴 mira 憑證外洩仍掛 07a)→ 各抓 raw 解析合併 → 最新檔的最後 commit 時間當「清單維護於」。
|
||||||
|
* 任一步失敗回 null → caller fallback dash_wait(標 age),不硬湊。
|
||||||
|
*/
|
||||||
|
async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingModel | null> {
|
||||||
|
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
|
||||||
|
const token = env.GITEA_TOKEN;
|
||||||
|
if (!base || !token) return null;
|
||||||
|
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||||
|
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||||
|
const headers = { Authorization: `token ${token}` };
|
||||||
|
try {
|
||||||
|
const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
|
||||||
|
if (!files) return null;
|
||||||
|
const sprints = pickLatestSprintFiles(files.map((f) => f.name));
|
||||||
|
if (!sprints.length) return null;
|
||||||
|
const parsed = await Promise.all(
|
||||||
|
sprints.map(async (name) => {
|
||||||
|
const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
|
||||||
|
if (!rawRes.ok) return null;
|
||||||
|
return parseSprintWaitingTable(await rawRes.text(), name);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const readFiles = sprints.filter((_, i) => parsed[i] !== null);
|
||||||
|
const merged = parsed.filter((p): p is WaitingItem[] => p !== null).flat();
|
||||||
|
if (!readFiles.length) return null; // 全部解析失敗=誠實 fallback
|
||||||
|
// 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale)
|
||||||
|
let ago = -1;
|
||||||
|
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
|
||||||
|
`${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
const date = commits?.[0]?.commit?.committer?.date;
|
||||||
|
if (date) {
|
||||||
|
const ms = Date.parse(date);
|
||||||
|
if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: sortWaitingItems(merged),
|
||||||
|
source: 'gitea_sprint',
|
||||||
|
updated_ago_minutes: ago,
|
||||||
|
stale: ago >= 0 && ago > 48 * 60,
|
||||||
|
sprint_files: readFiles,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||||
|
const graphUrl = graphBase(c.env);
|
||||||
|
|
||||||
const [beatEntries, taskEntries, waitEntries, inboxEntries] = await Promise.all([
|
const [
|
||||||
|
beatEntries,
|
||||||
|
taskEntries,
|
||||||
|
waitEntries,
|
||||||
|
inboxEntries,
|
||||||
|
giteaWaiting,
|
||||||
|
kbdbHealth,
|
||||||
|
embedStatus,
|
||||||
|
graphStats,
|
||||||
|
entriesTotal,
|
||||||
|
wikiCardTotal,
|
||||||
|
workflowTotal,
|
||||||
|
] = await Promise.all([
|
||||||
fetchEntries(c.env, tenant, 'dash_beat', 100),
|
fetchEntries(c.env, tenant, 'dash_beat', 100),
|
||||||
fetchEntries(c.env, tenant, 'dash_task', 200),
|
fetchEntries(c.env, tenant, 'dash_task', 200),
|
||||||
fetchEntries(c.env, tenant, 'dash_wait', 100),
|
fetchEntries(c.env, tenant, 'dash_wait', 100),
|
||||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||||
|
fetchGiteaWaiting(c.env, now),
|
||||||
|
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||||
|
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||||
|
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||||
|
fetchEntryTotal(c.env, {}),
|
||||||
|
fetchEntryTotal(c.env, { entry_type: 'wiki_card' }),
|
||||||
|
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)
|
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。
|
||||||
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
|
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
|
||||||
const seenActors = new Set<string>();
|
const seenActors = new Set<string>();
|
||||||
for (const e of beatEntries) {
|
for (const e of beatEntries) {
|
||||||
@@ -104,37 +190,26 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
|||||||
event: typeof j?.event === 'string' ? (j.event as string) : '',
|
event: typeof j?.event === 'string' ? (j.event as string) : '',
|
||||||
note: typeof j?.note === 'string' ? (j.note as string) : '',
|
note: typeof j?.note === 'string' ? (j.note as string) : '',
|
||||||
at: e.created_at,
|
at: e.created_at,
|
||||||
ago_minutes: ms === null ? -1 : Math.max(0, Math.round((now - ms) / 60000)),
|
ago_minutes: agoMinutes(now, ms),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
|
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
|
||||||
|
|
||||||
// dash_task:同 title 取最新(後寫蓋前寫)
|
// 今日路線:dash_task + 台北日曆日判定(純函式,見 lib/console-dashboard-model.ts)
|
||||||
const taskByTitle = new Map<string, { title: string; status: string; order: number; scope: string }>();
|
const route = buildRouteModel(taskEntries, now);
|
||||||
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
|
// 等你的事:Gitea sprint 等leo清單優先;讀不到 fallback dash_wait(帶 age + stale)
|
||||||
const waitByTitle = new Map<string, string>();
|
let waiting: WaitingModel;
|
||||||
for (const e of waitEntries) {
|
if (giteaWaiting) {
|
||||||
const j = parseJsonContent(e);
|
waiting = giteaWaiting;
|
||||||
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
} else {
|
||||||
if (!title || waitByTitle.has(title)) continue;
|
waiting = buildWaitingFallback(waitEntries, now);
|
||||||
waitByTitle.set(title, typeof j?.status === 'string' ? (j.status as string) : 'open');
|
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
|
||||||
|
waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料';
|
||||||
|
} else if (waiting.source === 'kbdb_dash_wait') {
|
||||||
|
waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const waiting = [...waitByTitle.entries()].filter(([, s]) => s === 'open').map(([title]) => ({ title }));
|
|
||||||
|
|
||||||
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
|
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
|
||||||
const inboxNew = inboxEntries.reduce((n, e) => {
|
const inboxNew = inboxEntries.reduce((n, e) => {
|
||||||
@@ -142,25 +217,69 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
|||||||
return j && j.status !== 'done' ? n + 1 : n;
|
return j && j.status !== 'done' ? n + 1 : n;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// 燈號
|
// 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
|
||||||
const hasBlocked = tasks.some((t) => t.status === 'blocked');
|
const todayWrites = route.tasks.filter((t) => t.is_today_write);
|
||||||
const hasLagMark = tasks.some((t) => !STANDARD_TASK_STATUS.has(t.status));
|
const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
|
||||||
|
const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
|
||||||
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
|
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
|
||||||
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
|
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
|
||||||
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
|
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
|
||||||
|
const kbdbOk = kbdbHealth?.ok === true;
|
||||||
const light: 'green' | 'yellow' | 'red' =
|
const light: 'green' | 'yellow' | 'red' =
|
||||||
hasBlocked || (inJudgeWindow && beatStale) ? 'red' : hasLagMark ? 'yellow' : 'green';
|
hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
|
||||||
|
const lightReason = !kbdbOk
|
||||||
|
? 'KBDB 基本盤 /health 打不通'
|
||||||
|
: hasBlocked
|
||||||
|
? '今日任務有 blocked'
|
||||||
|
: inJudgeWindow && beatStale
|
||||||
|
? `心跳超過 ${STALE_MINUTES} 分鐘`
|
||||||
|
: hasLagMark
|
||||||
|
? '今日任務有落後標記'
|
||||||
|
: '';
|
||||||
|
|
||||||
const todayTasks = tasks.filter((t) => t.scope === 'today');
|
|
||||||
return c.json({
|
return c.json({
|
||||||
light,
|
light,
|
||||||
|
light_reason: lightReason,
|
||||||
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
|
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
|
||||||
beats,
|
beats,
|
||||||
tasks,
|
// 路線:tasks 保留原欄位(console.ts 消費端相容),另帶 age 與 is_today_write
|
||||||
today_done: todayTasks.filter((t) => t.status === 'done').length,
|
tasks: route.tasks.map((t) => ({
|
||||||
today_total: todayTasks.length,
|
title: t.title,
|
||||||
waiting,
|
status: t.status,
|
||||||
|
order: t.order,
|
||||||
|
scope: t.scope,
|
||||||
|
age_minutes: t.age_minutes,
|
||||||
|
is_today_write: t.is_today_write,
|
||||||
|
})),
|
||||||
|
route_meta: {
|
||||||
|
source: 'kbdb_dash_task',
|
||||||
|
is_today: route.is_today,
|
||||||
|
updated_ago_minutes: route.updated_ago_minutes,
|
||||||
|
},
|
||||||
|
today_done: route.today_done,
|
||||||
|
today_total: route.today_total,
|
||||||
|
waiting: waiting.items,
|
||||||
|
waiting_meta: {
|
||||||
|
source: waiting.source,
|
||||||
|
updated_ago_minutes: waiting.updated_ago_minutes,
|
||||||
|
stale: waiting.stale,
|
||||||
|
sprint_files: waiting.sprint_files ?? null,
|
||||||
|
note: waiting.note ?? null,
|
||||||
|
},
|
||||||
inbox_new: inboxNew,
|
inbox_new: inboxNew,
|
||||||
|
system: {
|
||||||
|
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
|
||||||
|
embed: embedStatus
|
||||||
|
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||||
|
: null,
|
||||||
|
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||||
|
workflow_total: workflowTotal,
|
||||||
|
},
|
||||||
|
kb: {
|
||||||
|
entries_total: entriesTotal,
|
||||||
|
wiki_card_total: wikiCardTotal,
|
||||||
|
triplets_total: graphStats?.total ?? null,
|
||||||
|
},
|
||||||
generated_at: new Date(now).toISOString(),
|
generated_at: new Date(now).toISOString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -256,6 +375,8 @@ function renderDashboardHtml(): string {
|
|||||||
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
|
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
|
||||||
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
|
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
|
||||||
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
||||||
|
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
|
||||||
|
.wait-meta .warn { color: var(--err); }
|
||||||
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
|
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
|
||||||
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
|
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
|
||||||
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
|
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
|
||||||
@@ -269,6 +390,14 @@ function renderDashboardHtml(): string {
|
|||||||
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
||||||
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
||||||
ul.route li.blocked .ic { color: var(--err); }
|
ul.route li.blocked .ic { color: var(--err); }
|
||||||
|
ul.route.faded li { opacity: .55; }
|
||||||
|
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
|
||||||
|
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
|
||||||
|
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
|
||||||
|
.sys .ok { color: var(--ok); }
|
||||||
|
.sys .bad { color: var(--err); }
|
||||||
|
.sys .off { color: rgba(var(--ink-rgb),.5); }
|
||||||
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
||||||
.err { color: var(--err); font-size: 14px; }
|
.err { color: var(--err); font-size: 14px; }
|
||||||
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
||||||
@@ -307,11 +436,14 @@ function renderDashboardHtml(): string {
|
|||||||
<div class="wait-box" id="wait-box">
|
<div class="wait-box" id="wait-box">
|
||||||
<div class="wait-head">等你的事</div>
|
<div class="wait-head">等你的事</div>
|
||||||
<div id="wait-body" class="wait-none">載入中…</div>
|
<div id="wait-body" class="wait-none">載入中…</div>
|
||||||
|
<div class="wait-meta" id="wait-meta"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="subhead"><span class="t">今日路線</span><span class="m">狀態即時同步</span></div>
|
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-m"></span></div>
|
||||||
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
|
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
|
||||||
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
|
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
|
||||||
<ul class="route" id="week-list"></ul>
|
<ul class="route" id="week-list"></ul>
|
||||||
|
<div class="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
|
||||||
|
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
|
||||||
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
||||||
<a class="enter" href="/console">進入完整控制台 ›</a>
|
<a class="enter" href="/console">進入完整控制台 ›</a>
|
||||||
</main>
|
</main>
|
||||||
@@ -332,6 +464,12 @@ function renderDashboardHtml(): string {
|
|||||||
const ic = ICONS[t.status] || '●';
|
const ic = ICONS[t.status] || '●';
|
||||||
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
|
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
|
||||||
}
|
}
|
||||||
|
function humanAge(m) {
|
||||||
|
if (m == null || m < 0) return '時間不明';
|
||||||
|
if (m < 60) return m + ' 分鐘前';
|
||||||
|
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||||
|
return Math.round(m / 1440) + ' 天前';
|
||||||
|
}
|
||||||
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
|
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
|
||||||
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
|
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -350,6 +488,9 @@ function renderDashboardHtml(): string {
|
|||||||
const m = e && e.message ? String(e.message) : String(e);
|
const m = e && e.message ? String(e.message) : String(e);
|
||||||
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
||||||
}
|
}
|
||||||
|
function sysRow(k, v, cls) {
|
||||||
|
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/console/dashboard-data');
|
const res = await fetch('/console/dashboard-data');
|
||||||
@@ -361,28 +502,76 @@ function renderDashboardHtml(): string {
|
|||||||
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||||
$('orb-char').textContent = cfg.ch;
|
$('orb-char').textContent = cfg.ch;
|
||||||
$('orb-title').textContent = cfg.title;
|
$('orb-title').textContent = cfg.title;
|
||||||
$('orb-sub').textContent = d.last_beat
|
$('orb-sub').textContent = (d.last_beat
|
||||||
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
||||||
: '尚無心跳資料';
|
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||||
const done = d.today_done || 0, total = d.today_total || 0;
|
const done = d.today_done || 0, total = d.today_total || 0;
|
||||||
$('done-n').textContent = done; $('total-n').textContent = total;
|
$('done-n').textContent = done; $('total-n').textContent = total;
|
||||||
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
|
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
|
||||||
$('inbox-n').textContent = d.inbox_new || 0;
|
$('inbox-n').textContent = d.inbox_new || 0;
|
||||||
const wb = $('wait-box'), body = $('wait-body');
|
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
|
||||||
|
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
|
||||||
|
const wm = d.waiting_meta || {};
|
||||||
if (d.waiting && d.waiting.length) {
|
if (d.waiting && d.waiting.length) {
|
||||||
wb.classList.add('has');
|
wb.classList.add('has');
|
||||||
body.className = '';
|
body.className = '';
|
||||||
body.innerHTML = d.waiting.map((w) => '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>').join('');
|
body.innerHTML = d.waiting.map((w) =>
|
||||||
|
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||||
|
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
|
||||||
} else {
|
} else {
|
||||||
wb.classList.remove('has');
|
wb.classList.remove('has');
|
||||||
body.className = 'wait-none';
|
body.className = 'wait-none';
|
||||||
body.textContent = '無,你不用做任何事';
|
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||||
}
|
}
|
||||||
|
let metaTxt = '';
|
||||||
|
if (wm.source === 'gitea_sprint') {
|
||||||
|
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
|
||||||
|
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
|
||||||
|
} else if (wm.source === 'kbdb_dash_wait') {
|
||||||
|
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||||
|
} else {
|
||||||
|
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||||
|
}
|
||||||
|
wmeta.innerHTML = metaTxt;
|
||||||
|
// ── 今日路線:非今日寫入=誠實降級「最後路線(N 天前)」,不假裝是今天的 ──
|
||||||
|
const rm = d.route_meta || {};
|
||||||
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||||
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
if (rm.is_today) {
|
||||||
|
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||||
|
$('today-list').className = 'route';
|
||||||
|
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||||
|
} else if (today.length) {
|
||||||
|
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
|
||||||
|
$('today-list').className = 'route faded';
|
||||||
|
$('today-list').innerHTML =
|
||||||
|
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
|
||||||
|
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
|
||||||
|
} else {
|
||||||
|
$('route-m').textContent = '';
|
||||||
|
$('today-list').className = 'route';
|
||||||
|
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
|
||||||
|
}
|
||||||
$('week-head').style.display = week.length ? '' : 'none';
|
$('week-head').style.display = week.length ? '' : 'none';
|
||||||
$('week-list').innerHTML = week.map(taskLine).join('');
|
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||||
|
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
|
||||||
|
const sys = d.system || {}, kb = d.kb || {};
|
||||||
|
const rows = [];
|
||||||
|
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
|
||||||
|
if (sys.embed) {
|
||||||
|
rows.push(sys.embed.enabled
|
||||||
|
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')
|
||||||
|
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));
|
||||||
|
} else {
|
||||||
|
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||||
|
}
|
||||||
|
rows.push(sys.graph && sys.graph.ok
|
||||||
|
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||||
|
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||||
|
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||||
|
rows.push(sysRow('總庫規模', (kb.entries_total == null ? '讀不到' : kb.entries_total.toLocaleString() + ' 筆') +
|
||||||
|
'・wiki 卡 ' + (kb.wiki_card_total == null ? '?' : kb.wiki_card_total), kb.entries_total == null ? 'off' : ''));
|
||||||
|
$('sys-list').innerHTML = rows.join('');
|
||||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
|
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$('orb-char').textContent = '?';
|
$('orb-char').textContent = '?';
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ function renderConsoleHtml(registryBase: string): string {
|
|||||||
<div class="wait-box" id="ck-waitbox">
|
<div class="wait-box" id="ck-waitbox">
|
||||||
<div class="wait-head">等你的事</div>
|
<div class="wait-head">等你的事</div>
|
||||||
<div id="ck-wait" class="wait-none">載入中…</div>
|
<div id="ck-wait" class="wait-none">載入中…</div>
|
||||||
|
<div id="ck-waitmeta" style="margin-top:10px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.45);line-height:1.7"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -699,6 +700,12 @@ function renderConsoleHtml(registryBase: string): string {
|
|||||||
var cls = TASK_ICONS[t.status] ? t.status : 'blocked';
|
var cls = TASK_ICONS[t.status] ? t.status : 'blocked';
|
||||||
return '<li class="' + cls + '"><span class="ic">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';
|
return '<li class="' + cls + '"><span class="ic">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';
|
||||||
}
|
}
|
||||||
|
function ckAge(m) {
|
||||||
|
if (m == null || m < 0) return '時間不明';
|
||||||
|
if (m < 60) return m + ' 分鐘前';
|
||||||
|
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||||
|
return Math.round(m / 1440) + ' 天前';
|
||||||
|
}
|
||||||
function loadCockpit() {
|
function loadCockpit() {
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
$('ck-date').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
|
$('ck-date').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
|
||||||
@@ -711,24 +718,46 @@ function renderConsoleHtml(registryBase: string): string {
|
|||||||
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||||
$('ck-char').textContent = cfg.ch;
|
$('ck-char').textContent = cfg.ch;
|
||||||
$('ck-title').textContent = cfg.title;
|
$('ck-title').textContent = cfg.title;
|
||||||
$('ck-sub').textContent = d.last_beat
|
$('ck-sub').textContent = (d.last_beat
|
||||||
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
||||||
: '尚無心跳資料';
|
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||||
var done = d.today_done || 0, total = d.today_total || 0;
|
var done = d.today_done || 0, total = d.today_total || 0;
|
||||||
$('ck-done').textContent = done; $('ck-total').textContent = total;
|
$('ck-done').textContent = done; $('ck-total').textContent = total;
|
||||||
$('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
|
$('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
|
||||||
$('ck-inbox').textContent = d.inbox_new || 0;
|
$('ck-inbox').textContent = d.inbox_new || 0;
|
||||||
var wb = $('ck-waitbox'), body = $('ck-wait');
|
// 等你的事:來源與維護時間攤開講(fix/console-dashboard-live-data)——
|
||||||
|
// Gitea sprint 等leo清單是活資料源;fallback dash_wait 一定標 stale,不假裝是今天的
|
||||||
|
var wb = $('ck-waitbox'), body = $('ck-wait'), wmeta = $('ck-waitmeta');
|
||||||
|
var wm = d.waiting_meta || {};
|
||||||
if (d.waiting && d.waiting.length) {
|
if (d.waiting && d.waiting.length) {
|
||||||
wb.classList.add('has'); body.className = '';
|
wb.classList.add('has'); body.className = '';
|
||||||
body.innerHTML = d.waiting.map(function (w) { return '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>'; }).join('');
|
body.innerHTML = d.waiting.map(function (w) {
|
||||||
|
return '<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||||
|
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>';
|
||||||
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
wb.classList.remove('has'); body.className = 'wait-none';
|
wb.classList.remove('has'); body.className = 'wait-none';
|
||||||
body.textContent = '無,你不用做任何事';
|
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||||
}
|
}
|
||||||
|
if (wm.source === 'gitea_sprint') {
|
||||||
|
wmeta.innerHTML = '來源:sprint 等leo清單・維護於 ' + ckAge(wm.updated_ago_minutes) +
|
||||||
|
(wm.stale ? '<br><span class="err">⚠ 清單超過 2 天沒維護,可能過時</span>' : '');
|
||||||
|
} else if (wm.source === 'kbdb_dash_wait') {
|
||||||
|
wmeta.innerHTML = '<span class="err">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||||
|
} else if (wm.source === 'none') {
|
||||||
|
wmeta.innerHTML = '<span class="err">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||||
|
} else {
|
||||||
|
wmeta.textContent = '';
|
||||||
|
}
|
||||||
|
// 今日路線:非今日寫入=誠實降級為「最後路線(N 天前)」,不假裝是今天的
|
||||||
|
var rm = d.route_meta || {};
|
||||||
var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });
|
var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });
|
||||||
var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });
|
var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });
|
||||||
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
if (rm.is_today || !today.length) {
|
||||||
|
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目——dash_task 今天沒有寫入</span></li>';
|
||||||
|
} else {
|
||||||
|
$('ck-today').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線</span></li>' + today.map(taskLine).join('');
|
||||||
|
}
|
||||||
$('ck-weekhead').style.display = week.length ? '' : 'none';
|
$('ck-weekhead').style.display = week.length ? '' : 'none';
|
||||||
$('ck-week').innerHTML = week.map(taskLine).join('');
|
$('ck-week').innerHTML = week.map(taskLine).join('');
|
||||||
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
|
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
|
|||||||
// 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN,
|
// 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN,
|
||||||
// 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。
|
// 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。
|
||||||
|
|
||||||
function graphBase(env: Bindings): string {
|
export function graphBase(env: Bindings): string {
|
||||||
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, '');
|
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, '');
|
||||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export type Bindings = {
|
|||||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||||
CONSOLE_TENANT?: string;
|
CONSOLE_TENANT?: string;
|
||||||
|
// ── console 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-07-07)──
|
||||||
|
// 等 leo 的事真相源=InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護),
|
||||||
|
// 不是沒人維護的 KBDB dash_wait。cypher 進程內 fetch Gitea API(自架 Gitea,非 GitHub,
|
||||||
|
// 無 D20 頻率疑慮)。缺 GITEA_BASE_URL 或 GITEA_TOKEN → 該區塊 fallback dash_wait
|
||||||
|
// (頁面誠實標 age + stale),不假綠。
|
||||||
|
GITEA_BASE_URL?: string; // wrangler.toml [vars],如 https://git.uncle6.me
|
||||||
|
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||||
|
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||||
|
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||||
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
|
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
|
||||||
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
||||||
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* console 駕駛艙聚合純函式測試(fix/console-dashboard-live-data,2026-07-07)
|
||||||
|
*
|
||||||
|
* 針對 leo 抱怨「等你的事錯了好幾天」的三個 stale 根因逐一驗證:
|
||||||
|
* 1. dash_task 的 scope:"today" 沒日期 → 幾天前的任務不得再算「今日」
|
||||||
|
* 2. dash_wait 殘資料 fallback 必標 stale
|
||||||
|
* 3. sprint 檔「等 leo 清單」表格解析:已銷案(~~/✅/已完成)要濾掉、🔴 排最前
|
||||||
|
*
|
||||||
|
* 樣本資料直接取自 leo21c live KBDB 與 InkStoneCo sprint-2026-07a.md 的真實形態
|
||||||
|
* (epoch 秒 created_at、07-04 殘 entries、含劃掉列的 markdown 表格),非憑空編造。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseCreatedAtMs,
|
||||||
|
taipeiDayKey,
|
||||||
|
buildRouteModel,
|
||||||
|
buildWaitingFallback,
|
||||||
|
parseSprintWaitingTable,
|
||||||
|
sortWaitingItems,
|
||||||
|
cleanWaitingTitle,
|
||||||
|
pickLatestSprintFiles,
|
||||||
|
humanAge,
|
||||||
|
type KbdbEntry,
|
||||||
|
} from '../src/lib/console-dashboard-model';
|
||||||
|
|
||||||
|
// 2026-07-07 10:00 UTC(台北 18:00)當「現在」
|
||||||
|
const NOW = Date.parse('2026-07-07T10:00:00Z');
|
||||||
|
const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000);
|
||||||
|
|
||||||
|
function entry(created: string, content: unknown): KbdbEntry {
|
||||||
|
return { id: crypto.randomUUID(), entry_type: 'x', content: JSON.stringify(content), created_at: SEC(created) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseCreatedAtMs / taipeiDayKey', () => {
|
||||||
|
it('吃 epoch 秒(leo21c 實測形態)、epoch 毫秒、ISO、sqlite 字串', () => {
|
||||||
|
expect(parseCreatedAtMs(1783413742)).toBe(1783413742000);
|
||||||
|
expect(parseCreatedAtMs(1783413742000)).toBe(1783413742000);
|
||||||
|
expect(parseCreatedAtMs('2026-07-07T08:42:22Z')).toBe(Date.parse('2026-07-07T08:42:22Z'));
|
||||||
|
expect(parseCreatedAtMs('2026-07-07 08:42:22')).toBe(Date.parse('2026-07-07T08:42:22Z'));
|
||||||
|
expect(parseCreatedAtMs(null)).toBeNull();
|
||||||
|
});
|
||||||
|
it('台北日曆日:UTC 20:00 已是台北隔天', () => {
|
||||||
|
expect(taipeiDayKey(Date.parse('2026-07-06T20:00:00Z'))).toBe('2026-07-07');
|
||||||
|
expect(taipeiDayKey(Date.parse('2026-07-06T15:00:00Z'))).toBe('2026-07-06');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRouteModel — 今日路線不吃殘資料(stale 根因 2)', () => {
|
||||||
|
it('07-04 寫入的 scope:today 任務,07-07 看不再算今日(is_today=false、today_total=0)', () => {
|
||||||
|
// live 實態:dash_task 最後寫入 2026-07-04,07-07 dashboard 仍把它當「今日 2/4」
|
||||||
|
const entries = [
|
||||||
|
entry('2026-07-04T10:22:00Z', { title: 'T-loop4 檢討 routine 首跑', status: 'done', order: 1, scope: 'today' }),
|
||||||
|
entry('2026-07-04T04:44:00Z', { title: 'T-cockpit 駕駛艙三件套', status: 'doing', order: 2, scope: 'today' }),
|
||||||
|
entry('2026-07-04T04:44:00Z', { title: 'T-kb-skeleton 總庫 0→1', status: 'todo', order: 1, scope: 'week' }),
|
||||||
|
];
|
||||||
|
const m = buildRouteModel(entries, NOW);
|
||||||
|
expect(m.is_today).toBe(false);
|
||||||
|
expect(m.today_total).toBe(0); // 不假裝 07-04 的路線是今天的
|
||||||
|
expect(m.today_done).toBe(0);
|
||||||
|
expect(m.tasks).toHaveLength(3); // 資料仍在(頁面降級顯示「最後路線(N 天前)」)
|
||||||
|
expect(m.tasks.every((t) => !t.is_today_write)).toBe(true);
|
||||||
|
expect(m.updated_ago_minutes).toBeGreaterThan(3 * 24 * 60 - 60); // ≈3 天
|
||||||
|
});
|
||||||
|
it('今天寫入的任務照常計入今日(同 title 後寫蓋前寫)', () => {
|
||||||
|
const entries = [
|
||||||
|
entry('2026-07-07T09:00:00Z', { title: 'A', status: 'done', order: 1, scope: 'today' }), // 最新(DESC first)
|
||||||
|
entry('2026-07-07T08:00:00Z', { title: 'B', status: 'doing', order: 2, scope: 'today' }),
|
||||||
|
entry('2026-07-04T04:44:00Z', { title: 'A', status: 'todo', order: 1, scope: 'today' }), // 舊值被蓋
|
||||||
|
];
|
||||||
|
const m = buildRouteModel(entries, NOW);
|
||||||
|
expect(m.is_today).toBe(true);
|
||||||
|
expect(m.today_total).toBe(2);
|
||||||
|
expect(m.today_done).toBe(1);
|
||||||
|
expect(m.tasks.find((t) => t.title === 'A')?.status).toBe('done');
|
||||||
|
});
|
||||||
|
it('空庫:誠實回 -1 / false,不編資料', () => {
|
||||||
|
const m = buildRouteModel([], NOW);
|
||||||
|
expect(m.tasks).toHaveLength(0);
|
||||||
|
expect(m.is_today).toBe(false);
|
||||||
|
expect(m.updated_ago_minutes).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildWaitingFallback — dash_wait 殘資料必標 stale(stale 根因 1)', () => {
|
||||||
|
it('07-04 的 open 項在 07-07 仍列出,但 stale=true、age 正確', () => {
|
||||||
|
// live 實態:等leo清單#11 已於 07-05 銷案(誤判),dash_wait 卻沒人寫 closed
|
||||||
|
const entries = [
|
||||||
|
entry('2026-07-04T11:05:00Z', { title: '檢查 Claude Environment 兩個 routine trigger', status: 'open' }),
|
||||||
|
entry('2026-07-04T10:41:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'closed' }),
|
||||||
|
entry('2026-07-04T05:56:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'open' }), // 舊 open 被 closed 蓋
|
||||||
|
];
|
||||||
|
const m = buildWaitingFallback(entries, NOW);
|
||||||
|
expect(m.source).toBe('kbdb_dash_wait');
|
||||||
|
expect(m.items.map((i) => i.title)).toEqual(['檢查 Claude Environment 兩個 routine trigger']);
|
||||||
|
expect(m.stale).toBe(true); // >24h 沒維護 → 頁面必須警示「可能過時」
|
||||||
|
expect(m.updated_ago_minutes).toBeGreaterThan(24 * 60);
|
||||||
|
});
|
||||||
|
it('完全無資料 → source none + 管線未接 note', () => {
|
||||||
|
const m = buildWaitingFallback([], NOW);
|
||||||
|
expect(m.source).toBe('none');
|
||||||
|
expect(m.items).toHaveLength(0);
|
||||||
|
expect(m.note).toContain('管線未接');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSprintWaitingTable — 等leo清單活資料源(stale 根因 3 的解)', () => {
|
||||||
|
// 取自 InkStoneCo sprint-2026-07a.md 真實形態(截短),含:劃掉列、✅ 急迫欄、🔴/🟡/⚪
|
||||||
|
const MD = `# Sprint 2026-07a
|
||||||
|
## 任務板
|
||||||
|
- [x] 東西
|
||||||
|
|
||||||
|
## 等 leo 清單(暗待辦歸零區)
|
||||||
|
|
||||||
|
| # | 事項 | 急迫 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | ~~Claude Environment 補 secrets~~ ✅ 07-02 實測確認 | ⚪ 已完成 |
|
||||||
|
| 3 | Telegram bot token rotate(可與 #1 一起做) | 🟡 建議今天順手 |
|
||||||
|
| 9 | **credential 路徑1 不成立,SDD 需重新設計**(詳情見 Arcrun#2) | ✅ 已解,T2-T9 解凍 |
|
||||||
|
| 11 | ~~cloud-worker trigger 連續兩天疑似未觸發~~ **✅ 銷案=誤判** | ✅ 銷案(誤判)+防複發已上線 |
|
||||||
|
| 14 | **\`NPM_API_TOKEN\` 缺失,acr search 等 CLI 改動全數卡在 npm 未發版**(2026-07-05 核實):Environment 現只有五把 | 🟡 建議本週補 |
|
||||||
|
| 13 | 🔴 **mira \`.env\` 歷史憑證外洩(2026-07-05 發現,已止血未 rotate)**:mira repo 曾把 .env 誤入 git 追蹤 | 🔴 需 leo 裁(安全事件) |
|
||||||
|
| 15 | **T-cockpit③(md→Gitea Project 投影)方向選擇**(2026-07-06 spike 核實):需 leo 三選一 | 🟡 品味/方向題 |
|
||||||
|
|
||||||
|
### 下一段
|
||||||
|
別的內容`;
|
||||||
|
|
||||||
|
it('已銷案列(~~ 或急迫欄 ✅/已完成)全濾掉;open 項留下、🔴 排最前', () => {
|
||||||
|
const items = parseSprintWaitingTable(MD, 'sprint-2026-07a.md')!;
|
||||||
|
expect(items).not.toBeNull();
|
||||||
|
expect(items.map((i) => i.id)).toEqual(['13', '3', '14', '15']); // 🔴 最前,餘依表序
|
||||||
|
expect(items[0].urgency).toBe('🔴');
|
||||||
|
expect(items[0].title).toContain('mira');
|
||||||
|
expect(items[0].sprint).toBe('sprint-2026-07a.md');
|
||||||
|
expect(items.every((i) => !i.title.includes('~~') && !i.title.includes('**'))).toBe(true);
|
||||||
|
});
|
||||||
|
it('07b 形態:段落標題帶註記「(RAG 線)」、編號帶字母前綴 R1', () => {
|
||||||
|
const md07b = `## 等 leo 清單(RAG 線)
|
||||||
|
|
||||||
|
| # | 事項 | 急迫 |
|
||||||
|
|---|------|------|
|
||||||
|
| R1 | pilot 客戶挑選(1–2 家)+產品命名/定價區間(rag-wave1 T0) | 🟡 Phase 0 前置 |
|
||||||
|
| R2 | ~~Gemini key~~ ✅ 已補 | ⚪ 已完成 |
|
||||||
|
`;
|
||||||
|
const items = parseSprintWaitingTable(md07b, 'sprint-2026-07b.md')!;
|
||||||
|
expect(items.map((i) => i.id)).toEqual(['R1']);
|
||||||
|
expect(items[0].urgency).toBe('🟡');
|
||||||
|
});
|
||||||
|
it('跨 sprint 合併排序:現役檔項目先傳,🔴 仍浮最上', () => {
|
||||||
|
const b = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| R1 | RAG pilot | 🟡 x |', 'b.md')!;
|
||||||
|
const a = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| 13 | 憑證外洩 | 🔴 x |', 'a.md')!;
|
||||||
|
const merged = sortWaitingItems([...b, ...a]);
|
||||||
|
expect(merged.map((i) => i.id)).toEqual(['13', 'R1']);
|
||||||
|
});
|
||||||
|
it('沒有等 leo 段落 → null(caller 誠實 fallback,不硬湊)', () => {
|
||||||
|
expect(parseSprintWaitingTable('# 別的檔\n沒有清單')).toBeNull();
|
||||||
|
expect(parseSprintWaitingTable('## 等 leo 清單\n(還沒建表)')).toBeNull();
|
||||||
|
});
|
||||||
|
it('cleanWaitingTitle:去粗體、長標題在「:」截斷、80 字上限', () => {
|
||||||
|
expect(cleanWaitingTitle('**NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版**(07-05 核實):Environment 現只有五把')).toBe(
|
||||||
|
'NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版(07-05 核實)',
|
||||||
|
);
|
||||||
|
expect(cleanWaitingTitle('短標題').length).toBeLessThan(80);
|
||||||
|
});
|
||||||
|
it('cleanWaitingTitle:去開頭急迫 emoji(急迫欄已有)、截斷後不留未閉合括號', () => {
|
||||||
|
// 等leo清單#13 實際形態:事項欄自帶 🔴 開頭
|
||||||
|
expect(cleanWaitingTitle('🔴 **mira `.env` 歷史憑證外洩(2026-07-05 發現)**:mira repo 曾把 .env 誤入 git')).toBe(
|
||||||
|
'mira .env 歷史憑證外洩(2026-07-05 發現)',
|
||||||
|
);
|
||||||
|
// 等leo清單#3 實際形態:「:」在括號內 → 截斷會留半個「(」,要剪掉
|
||||||
|
expect(cleanWaitingTitle('Telegram bot token rotate(可與 #1 一起做:rotate 後直接填新值進 Environment)')).toBe(
|
||||||
|
'Telegram bot token rotate',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickLatestSprintFiles / humanAge', () => {
|
||||||
|
it('sprint-*.md 字典序取最新兩個(新→舊);無 sprint 檔回空陣列', () => {
|
||||||
|
// live 實態(2026-07-07):07b 已開、07a 的 🔴 未銷案項仍要讀 → 兩檔都拿
|
||||||
|
expect(pickLatestSprintFiles(['sprint-2026-07a.md', 'sprint-2026-07b.md', 'tasks.md', 'routines'])).toEqual([
|
||||||
|
'sprint-2026-07b.md',
|
||||||
|
'sprint-2026-07a.md',
|
||||||
|
]);
|
||||||
|
expect(pickLatestSprintFiles(['sprint-2026-06b.md', 'sprint-2026-07a.md', 'sprint-2026-05a.md'])).toEqual([
|
||||||
|
'sprint-2026-07a.md',
|
||||||
|
'sprint-2026-06b.md',
|
||||||
|
]);
|
||||||
|
expect(pickLatestSprintFiles(['tasks.md'])).toEqual([]);
|
||||||
|
});
|
||||||
|
it('humanAge 誠實顯示天級 age', () => {
|
||||||
|
expect(humanAge(-1)).toBe('時間不明');
|
||||||
|
expect(humanAge(30)).toBe('30 分鐘前');
|
||||||
|
expect(humanAge(3 * 24 * 60)).toBe('3 天前');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,6 +138,15 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
|
|||||||
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
||||||
CONSOLE_TENANT = "leo"
|
CONSOLE_TENANT = "leo"
|
||||||
|
|
||||||
|
# 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-07-07):
|
||||||
|
# InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有
|
||||||
|
# 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。
|
||||||
|
# ⚠ 還需 `wrangler secret put GITEA_TOKEN`(建議唯讀 scope)才會啟用;缺 token → dashboard
|
||||||
|
# 「等你的事」fallback dash_wait 並誠實標 age/stale。Self-hosted fork:清空 GITEA_BASE_URL 即整段停用。
|
||||||
|
GITEA_BASE_URL = "https://git.uncle6.me"
|
||||||
|
GITEA_SPRINT_REPO = "Leo/InkStoneCo"
|
||||||
|
GITEA_SPRINT_DIR = "system-dev/docs/3-specs/autonomy-dispatch"
|
||||||
|
|
||||||
[[routes]]
|
[[routes]]
|
||||||
pattern = "cypher.arcrun.dev/*"
|
pattern = "cypher.arcrun.dev/*"
|
||||||
zone_name = "arcrun.dev"
|
zone_name = "arcrun.dev"
|
||||||
|
|||||||
Reference in New Issue
Block a user