@@ -0,0 +1,822 @@
/ * *
* arcrun console 駕 駛 艙 dashboard ( T - cockpit ② , Arcrun # 3 console 系 , 2026 - 07 - 04 總 管 派 工 ;
* 2026 - 07 - 07 fix / console - dashboard - live - data : stale 資 料 整 修 , 總 管 交 辦 )
*
* 端 點 皆 「 無 需 登 入 」 ( 唯 讀 、 不 吐 機 敏 值 — — 只 回 聚 合 後 的 狀 態 燈 / 任 務 標 題 / 計 數 ) :
* - GET / console / dashboard - data : 聚 合 JSON 。
* - GET / console / dashboard : 單 檔 HTML ( 同 console . ts 薄 殼 風 格 ) , 每 60 秒 自 動 刷 新 。
* - GET / console / kb - scale - data : 精 耕 層 規 模 ( wiki 卡 / 三 元 組 / 已 嵌 入 ; 2026 - 07 - 07 leo 裁
* 「 遺 產 庫 不 用 顯 示 」 後 console 頭 部 統 計 改 讀 這 裡 ) 。
* - GET / console / settings - data : 設 定 頁 誠 實 系 統 值 ( MCP token TTL 佔 位 ) 。
*
* ─ ─ 2026 - 07 - 07 二 修 ( fix / console - truth - audit ) : 「 今 日 完 成 / 今 日 路 線 」 接 sprint 任 務 板 ─ ─
* leo 拍 板 ( 「 今 天 做 了 這 麼 多 事 … … 其 實 就 是 我 們 到 底 完 成 了 多 少 事 」 ) : dash_task 同 dash_wait
* 病 ( 沒 活 管 線 ) , 真 相 源 = sprint 檔 「 # # 任 務 板 」 勾 選 。 比 照 「 等 你 的 事 」 # 36 模 式 : 同 一 輪
* Gitea fetch ( 90 s 快 取 共 用 ) 解 析 任 務 板 , 「 今 日 完 成 」 只 認 「 完 成 ( 今 天 台 北 日 ) 」 標 記 ,
* dash_task 降 fallback ; 板 檔 今 天 沒 commit → 頁 面 誠 實 標 「 今 日 任 務 板 未 更 新 ( 最 後 N 小 時 前 ) 」 。
*
* ─ ─ 2026 - 07 - 07 整 修 : 每 個 區 塊 都 讀 「 live 一 手 資 料 」 , 讀 不 到 就 誠 實 標 示 , 不 擺 stale 殘 骸 ─ ─
*
* 資 料 源 診 斷 ( leo 抱 怨 「 等 你 的 事 錯 了 好 幾 天 」 的 根 因 ) :
* - dash_wait ( 等 你 的 事 舊 資 料 源 ) 最 後 寫 入 2026 - 07 - 04 , * * 沒 有 活 的 維 護 管 線 * * — — 等 leo清單 # 11
* 已 於 07 - 05 銷 案 ( 誤 判 ) , dashboard 卻 繼 續 掛 著 它 。 真 相 源 其 實 是 InkStoneCo sprint 檔 的
* 「 # # 等 leo 清 單 」 表 格 ( progress - guard routine 每 日 核 實 維 護 ) 。
* - 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 = 「 今 日 寫 入 」 的 任 務 有 blocked , 或 最 新 心 跳 距 今 > 240 分 ( 台 北 09 :00 - 22 :00 窗 內 判 定 ) ,
* 或 KBDB / health 打 不 通 。 stale 殘 任 務 * * 不 再 * * 觸 發 燈 號 ( 07 - 04 的 blocked 不 該 讓 07 - 07 亮 紅 ) 。
* yellow = 無 red 條 件 , 但 今 日 任 務 有 非 標 準 status ( late / behind 等 落 後 標 記 ) 。
* green = 其 餘 。
*
* 薄 殼 定 位 : 聚 合 端 點 ( 能 力 長 在 API 一 次 , rule 07 正 例 ) — — 頁 面 零 業 務 邏 輯 ; 判 定 純 函 式 抽 在
* lib / console - dashboard - model . ts ( 可 單 測 ) 。 讀 KBDB 走 HTTP ( kbdbBase 慣 例 ) , 不 新 增 binding 。
* /
import { Hono } from 'hono' ;
import type { Bindings } from '../types' ;
import { kbdbBase , graphBase } from './kbdb-proxy' ;
import { validateConsoleSession } from './console-auth' ;
import {
type KbdbEntry ,
type WaitingItem ,
type WaitingModel ,
type CachedWaitingEnvelope ,
type SprintBoardTask ,
type SprintSnapshot ,
GITEA_WAITING_CACHE_TTL_SECONDS ,
parseCreatedAtMs ,
parseJsonContent ,
agoMinutes ,
buildRouteModel ,
buildSprintRouteModel ,
buildWaitingFallback ,
parseSprintTaskBoard ,
parseSprintWaitingTable ,
pickLatestSprintFiles ,
reviveWaitingAges ,
sortWaitingItems ,
taipeiDayKey ,
} from '../lib/console-dashboard-model' ;
import { applyTriageCheck , buildTriageModel , type TriageCheckAction } from '../lib/console-triage-model' ;
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time' ;
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' ] ) ;
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 ) } ) ;
try {
const res = await fetch ( ` ${ base } /entries? ${ params . toString ( ) } ` , { headers } ) ;
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 {
return null ;
}
}
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
async function fetchEntryTotal ( env : Bindings , filters : Record < string , string > ) : Promise < number | null > {
const { base , headers } = kbdbBase ( env ) ;
const params = new URLSearchParams ( { . . . filters , limit : '1' } ) ;
const data = await fetchJson < { total? : unknown } > ( ` ${ base } /entries? ${ params . toString ( ) } ` , headers ) ;
return data && typeof data . total === 'number' ? data.total : null ;
}
/ * *
* sprint 檔 活 資 料 源 ( 同 一 輪 fetch 兩 個 產 物 , leo 2026 - 07 - 07 拍 板 加 「 今 日 完 成 」 ) :
* - 「 等 你 的 事 」 = 「 # # 等 leo 清 單 」 表 格 ( progress - guard 每 日 維 護 ) 。
* - 「 今 日 完 成 / 今 日 路 線 」 = 「 # # 任 務 板 」 checkbox ( 今 天 勾 的 才 算 今 日 完 成 ) 。
* 需 GITEA_BASE_URL ( var ) + GITEA_TOKEN ( secret , 建 議 唯 讀 scope ) 。 請 求 序 :
* 列 目 錄 挑 最 新 兩 個 sprint - * . md ( 換 sprint 後 前 一 檔 常 還 有 未 銷 案 項 / 未 收 項 , 例 : 07 b 開 了 、
* 🔴 mira 憑 證 外 洩 與 [ 🔄 ] T - cockpit 仍 掛 07 a ) → 各 抓 raw 、 兩 個 parser 吃 同 一 份 文 字 →
* 最 新 檔 的 最 後 commit 時 間 當 「 維 護 於 」 。 等 leo清單全解析失敗回 null → caller fallback
* dash_wait / dash_task ( 標 age ) , 不 硬 湊 。
* /
async function fetchGiteaSprint ( env : Bindings , nowMs : number ) : Promise < SprintSnapshot | 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 ;
const text = await rawRes . text ( ) ;
return { waiting : parseSprintWaitingTable ( text , name ) , board : parseSprintTaskBoard ( text , name ) } ;
} ) ,
) ;
const readFiles = sprints . filter ( ( _ , i ) = > parsed [ i ] ? . waiting != null ) ;
const merged = parsed . map ( ( p ) = > p ? . waiting ) . filter ( ( p ) : p is WaitingItem [ ] = > p != null ) . flat ( ) ;
if ( ! readFiles . length ) return null ; // 等leo清單全部解析失敗=誠實 fallback
// 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → null( fallback dash_task)
const boardMerged = parsed . map ( ( p ) = > p ? . board ) . filter ( ( b ) : b is SprintBoardTask [ ] = > b != null ) . flat ( ) ;
// 清單上次維護時間 = 現役 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 {
waiting : {
items : sortWaitingItems ( merged ) ,
source : 'gitea_sprint' ,
updated_ago_minutes : ago ,
stale : ago >= 0 && ago > 48 * 60 ,
sprint_files : readFiles ,
} ,
board : boardMerged.length ? boardMerged : null ,
} ;
} catch {
return null ;
}
}
export type GiteaSprintFetcher = ( env : Bindings , nowMs : number ) = > Promise < SprintSnapshot | null > ;
/ * *
* fetchGiteaSprint 的 快 取 層 ( 總 管 # 36 審 查 要 求 ) : CF Cache API ( caches . default ) 、
* TTL 90 s ( GITEA_WAITING_CACHE_TTL_SECONDS ) 。 前 端 60 秒 刷 新 下 , Gitea 從
* 「 每 分 鐘 3 - 4 個 API call 」 降 到 「 ≤ 1 輪 / 90 s 」 ; 快 取 是 查 詢 面 的 讀 優 化 , 不 是 輪 詢 。
*
* - key : 合 成 URL ( Cache API 要 求 合 法 URL ; host 用 不 會 真 的 被 打 的 保 留 名 ) , 帶
* base / repo / dir 參 數 — — 設 定 變 了 自 然 miss , 不 會 吐 到 別 的 Gitea 的 殘 資 料 。
* - hit 回 放 時 用 reviveWaitingAges 把 「 維 護 於 N 分 鐘 前 」 隨 牆 鐘 補 算 ( 存 的 是 fetch
* 當 下 的 ago , 直 接 回 放 會 讓 時 間 停 走 ) 。 任 務 板 存 原 始 completed_days , 「 今 天 完 成 幾 件 」
* 由 請 求 當 下 算 — — 跨 台 北 午 夜 的 快 取 不 會 把 昨 天 的 完 成 冒 領 成 今 天 。
* - * * 失 敗 不 快 取 * * : negative cache 會 把 一 時 網 路 抖 動 放 大 成 90 秒 盲 區 , caller 該
* 當 場 fallback dash_wait / dash_task 。
* - cache . put 走 waitUntil ( 不 阻 塞 回 應 ) ; fetcher 參 數 可 注 入 = 單 測 不 用 真 打 網 路 。
* - 回 傳 多 帶 cache : 'hit' | 'miss' , 吐 進 waiting_meta 當 快 取 生 效 的 客 觀 證 據 ( curl 兩 次
* 第 二 次 該 是 hit ) 。
* /
export async function cachedGiteaSprint (
env : Bindings ,
nowMs : number ,
waitUntil : ( p : Promise < unknown > ) = > void ,
fetcher : GiteaSprintFetcher = fetchGiteaSprint ,
) : Promise < ( SprintSnapshot & { cache : 'hit' | 'miss' } ) | null > {
if ( ! env . GITEA_BASE_URL || ! env . GITEA_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 cacheKey = new Request (
` https://console-dashboard.arcrun.internal/gitea-waiting? ${ new URLSearchParams ( { base : env.GITEA_BASE_URL , repo , dir } ).toString()} ` ,
) ;
const cache = caches . default ;
try {
const hit = await cache . match ( cacheKey ) ;
if ( hit ) {
const envelope = ( await hit . json ( ) ) as CachedWaitingEnvelope ;
return {
waiting : reviveWaitingAges ( envelope . snapshot . waiting , envelope . fetched_at_ms , nowMs ) ,
board : envelope.snapshot.board ,
cache : 'hit' ,
} ;
}
} catch {
/* cache 故障不致命,走 miss 路徑 */
}
const fresh = await fetcher ( env , nowMs ) ;
if ( ! fresh ) return null ; // 失敗不快取,caller 誠實 fallback
const envelope : CachedWaitingEnvelope = { snapshot : fresh , fetched_at_ms : nowMs } ;
try {
waitUntil (
cache . put (
cacheKey ,
new Response ( JSON . stringify ( envelope ) , {
headers : {
'Content-Type' : 'application/json' ,
'Cache-Control' : ` public, max-age= ${ GITEA_WAITING_CACHE_TTL_SECONDS } ` ,
} ,
} ) ,
) ,
) ;
} catch {
/* put 失敗只是少了快取,不影響本次回應 */
}
return { . . . fresh , cache : 'miss' } ;
}
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter . get ( '/console/dashboard-data' , async ( c ) = > {
const tenant = c . env . CONSOLE_TENANT || 'leo' ;
const now = Date . now ( ) ;
const { base : kbdbUrl , headers : kbdbHeaders } = kbdbBase ( c . env ) ;
const graphUrl = graphBase ( c . env ) ;
const [
beatEntries ,
taskEntries ,
waitEntries ,
inboxEntries ,
giteaSprint ,
kbdbHealth ,
embedStatus ,
graphStats ,
entriesTotal ,
wikiCardTotal ,
workflowTotal ,
] = 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 ) ,
cachedGiteaSprint ( c . env , now , ( p ) = > c . executionCtx . waitUntil ( p ) ) ,
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 ` ) ,
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
fetchEntryTotal ( c . env , { owner_id : tenant } ) ,
fetchEntryTotal ( c . env , { entry_type : 'wiki_card' , owner_id : tenant } ) ,
fetchEntryTotal ( c . env , { entry_type : 'workflow' , owner_id : tenant } ) ,
] ) ;
// 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 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 : agoMinutes ( now , ms ) ,
} ) ;
}
const lastBeat = beats . filter ( ( b ) = > b . ago_minutes >= 0 ) . sort ( ( a , b ) = > a . ago_minutes - b . ago_minutes ) [ 0 ] ? ? null ;
// 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale)
let waiting : WaitingModel ;
let waitingCache : 'hit' | 'miss' | null = null ;
if ( giteaSprint ) {
waiting = giteaSprint . waiting ;
waitingCache = giteaSprint . cache ;
} else {
waiting = buildWaitingFallback ( waitEntries , now ) ;
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 殘資料' ;
}
}
// 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
// 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
// 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
const sprintRoute = giteaSprint ? . board ? buildSprintRouteModel ( giteaSprint . board , now ) : null ;
const route = buildRouteModel ( taskEntries , now ) ; // fallback + 燈號仍吃 dash_task 今日寫入
const boardAgo = giteaSprint ? giteaSprint . waiting . updated_ago_minutes : - 1 ;
const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey ( now - boardAgo * 60000 ) === taipeiDayKey ( now ) ;
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
const inboxNew = inboxEntries . reduce ( ( n , e ) = > {
const j = parseJsonContent ( e ) ;
return j && j . status !== 'done' ? n + 1 : n ;
} , 0 ) ;
// 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
const todayWrites = route . tasks . filter ( ( t ) = > t . is_today_write ) ;
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 inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR ;
const beatStale = lastBeat === null || lastBeat . ago_minutes > STALE_MINUTES ;
const kbdbOk = kbdbHealth ? . ok === true ;
const light : 'green' | 'yellow' | 'red' =
hasBlocked || ( inJudgeWindow && beatStale ) || ! kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green' ;
const lightReason = ! kbdbOk
? 'KBDB 基本盤 /health 打不通'
: hasBlocked
? '今日任務有 blocked'
: inJudgeWindow && beatStale
? ` 心跳超過 ${ STALE_MINUTES } 分鐘 `
: hasLagMark
? '今日任務有落後標記'
: '' ;
return c . json ( {
light ,
light_reason : lightReason ,
last_beat : lastBeat ? { actor : lastBeat.actor , ago_minutes : lastBeat.ago_minutes , event : lastBeat.event , note : lastBeat.note } : null ,
beats ,
// 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
// 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB,
// 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
tasks : sprintRoute
? sprintRoute . tasks . map ( ( t , i ) = > ( {
title : t.title ,
status : t.status ,
order : i ,
scope : 'today' as const ,
age_minutes : boardAgo ,
is_today_write : t.status === 'done' , // done 項必然是「今天完成」的(模型已濾)
sprint : t.sprint ? ? null ,
} ) )
: route . tasks . map ( ( t ) = > ( {
title : t.title ,
status : t.status ,
order : t.order ,
scope : t.scope ,
age_minutes : t.age_minutes ,
is_today_write : t.is_today_write ,
sprint : null ,
} ) ) ,
route_meta : sprintRoute
? {
source : 'gitea_sprint_board' ,
// is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
is_today : boardUpdatedToday ,
updated_ago_minutes : boardAgo ,
sprint_files : waiting.sprint_files ? ? null ,
}
: {
source : 'kbdb_dash_task' ,
is_today : route.is_today ,
updated_ago_minutes : route.updated_ago_minutes ,
sprint_files : null ,
} ,
today_done : sprintRoute ? sprintRoute.today_done : route.today_done ,
today_total : sprintRoute ? sprintRoute.today_total : route.today_total ,
done_today_titles : sprintRoute ? sprintRoute.done_today_titles : null ,
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 ,
// Gitea 快取層狀態(hit/miss; fallback 路徑為 null)——快取生效的客觀證據
cache : waitingCache ,
} ,
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 ( ) ,
} ) ;
} ) ;
// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
consoleDashboardRouter . get ( '/console/kb-scale-data' , async ( c ) = > {
const tenant = c . env . CONSOLE_TENANT || 'leo' ;
const { base , headers } = kbdbBase ( c . env ) ;
const graphUrl = graphBase ( c . env ) ;
const now = Date . now ( ) ;
const [ wikiCards , graphStats , embedStatus ] = await Promise . all ( [
// limit=1 順手拿最新一筆 created_at( list 為 created_at DESC)=「最近寫入時間」
fetchJson < { total? : number ; entries ? : { created_at? : string | number } [ ] } > (
` ${ base } /entries? ${ new URLSearchParams ( { owner_id : tenant , entry_type : 'wiki_card' , limit : '1' } ).toString()} ` ,
headers ,
) ,
fetchJson < { total? : number } > ( ` ${ graphUrl } /triplets/stats ` ) ,
fetchJson < { enabled? : boolean ; embedded? : number ; pending? : number } > ( ` ${ base } /embed/backfill/status ` , headers ) ,
] ) ;
const latestMs = parseCreatedAtMs ( wikiCards ? . entries ? . [ 0 ] ? . created_at ? ? null ) ;
// 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
return c . json ( {
wiki_card_total : typeof wikiCards ? . total === 'number' ? wikiCards.total : null ,
wiki_card_latest_ago_minutes : latestMs === null ? - 1 : agoMinutes ( now , latestMs ) ,
triplets_total : typeof graphStats ? . total === 'number' ? graphStats.total : null ,
embedded : embedStatus?.embedded ? ? null ,
embed_enabled : embedStatus ? embedStatus . enabled === true : null ,
generated_at : new Date ( now ) . toISOString ( ) ,
} ) ;
} ) ;
// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`( mcp/src/types.ts,預設 2592000= 30 天);
// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
consoleDashboardRouter . get ( '/console/settings-data' , ( c ) = > {
const raw = c . env . MCP_TOKEN_TTL ;
const parsed = raw ? parseInt ( raw , 10 ) : NaN ;
const fromEnv = Number . isFinite ( parsed ) && parsed > 0 ;
return c . json ( {
mcp_token_ttl_seconds : fromEnv ? parsed : 2592000 ,
mcp_token_ttl_source : fromEnv ? 'env' : 'default' ,
} ) ;
} ) ;
// GET /console/triage-data — 分流台資料(Mira Console 頁 7, Arcrun#9 收件夾改裝;原
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
// **需 console session**( Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
// 資料源二合一(kb-ingest SDD R7):entry_type=todo( Logseq 萃取,Arcrun#8 ingest 線)+
// entry_type=inbox( Telegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。
consoleDashboardRouter . get ( '/console/triage-data' , async ( c ) = > {
const ok = await validateConsoleSession ( c . env , c . req . header ( 'authorization' ) ) ;
if ( ! ok ) return c . json ( { error : '需要登入(console session) ' } , 401 ) ;
const tenant = c . env . CONSOLE_TENANT || 'leo' ;
const [ todoEntries , inboxEntries ] = await Promise . all ( [
fetchEntries ( c . env , tenant , 'todo' , 500 ) ,
fetchEntries ( c . env , tenant , 'inbox' , 200 ) ,
] ) ;
const model = buildTriageModel ( todoEntries , inboxEntries ) ;
return c . json ( { . . . model , generated_at : new Date ( ) . toISOString ( ) } ) ;
} ) ;
// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN( token 只能在
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
// triage-data 同款 session 驗證,server 端做 KBDB PATCH( kbdbBase 慣例)。
//
// PATCH content 需**整串回寫**( KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
//
// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done( checked_via:"logseq")。
// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
consoleDashboardRouter . post ( '/console/triage-check' , async ( c ) = > {
const ok = await validateConsoleSession ( c . env , c . req . header ( 'authorization' ) ) ;
if ( ! ok ) return c . json ( { error : '需要登入(console session) ' } , 401 ) ;
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
const entryId = typeof body ? . entry_id === 'string' ? body . entry_id . trim ( ) : '' ;
if ( ! entryId ) return c . json ( { error : 'entry_id 必填' } , 400 ) ;
const action : TriageCheckAction = body ? . action === 'restore' ? 'restore' : 'check' ;
const tenant = c . env . CONSOLE_TENANT || 'leo' ;
const { base , headers } = kbdbBase ( c . env ) ;
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
// 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
// 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
const got = await fetchJson < { entry ? : { owner_id? : string ; entry_type? : string ; content? : string | null } } > (
` ${ base } /entries/ ${ encodeURIComponent ( entryId ) } ` ,
headers ,
) ;
const entry = got ? . entry ;
if ( ! entry ) return c . json ( { error : '找不到這筆待辦(可能已被刪除)' } , 404 ) ;
if ( entry . owner_id !== tenant ) return c . json ( { error : '找不到這筆待辦(可能已被刪除)' } , 404 ) ; // 不洩漏他租戶存在性
if ( entry . entry_type !== 'todo' && entry . entry_type !== 'inbox' ) {
return c . json ( { error : '只有分流台項目(todo/inbox)能在這裡勾掉' } , 400 ) ;
}
const newContent = applyTriageCheck ( entry . content , action , new Date ( ) . toISOString ( ) ) ;
const res = await fetch ( ` ${ base } /entries/ ${ encodeURIComponent ( entryId ) } ` , {
method : 'PATCH' ,
headers ,
body : JSON.stringify ( { content : newContent } ) ,
} ) ;
if ( ! res . ok ) return c . json ( { error : ` KBDB 回寫失敗(HTTP ${ res . status } ) ` } , 502 ) ;
return c . json ( { success : true , entry_id : entryId , action , status : action === 'restore' ? 'new' : 'done' } ) ;
} ) ;
function renderDashboardHtml ( brand : string ) : string {
return ` <!doctype html>
< html lang = "zh-Hant" >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > $ { brand } 駕 駛 艙 < / title >
< script >
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
document . documentElement . setAttribute ( 'data-theme' , ( function ( ) {
try { return localStorage . getItem ( 'arcrun_console_theme' ) === 'dark' ? 'dark' : 'light' ; } catch ( e ) { return 'light' ; }
} ) ( ) ) ;
< / script >
< style >
/ * M i r a C o n s o l e 定 稿 視 覺 ( 紙 感 「 2 a 」 , M i r a S t y l e G u i d e 2 0 2 6 - 0 7 - 0 4 ) :
紙 紋 底 repeating - linear - gradient 、 明 體 標 題 級 聯 、 琥 珀 強 調 、 呼 吸 狀 態 球 嵌 單 字 。
2026 - 07 - 04 二 輪 : CSS custom properties 兩 份 色 板 — — 預 設 淺 色 ( 宣 紙 米 白 + 墨 字 ) , 深 色 = 原 定 稿 暖 黑 不 動 。 * /
* { box - sizing : border - box ; }
: root {
-- paper - a : # f4eddc ; -- paper - b : # f1e9d6 ;
-- ink : # 2 f2a20 ; -- ink - rgb : 30 , 24 , 14 ;
-- amber : # 8 a5f1e ; -- amber - rgb : 138 , 95 , 30 ;
-- ok : # 1 d7a48 ; -- ok - rgb : 29 , 122 , 72 ;
-- err : # b03a26 ; -- err - rgb : 176 , 58 , 38 ;
-- track : rgba ( 30 , 24 , 14 , . 12 ) ;
}
: root [ data - theme = "dark" ] {
-- paper - a : # 191410 ; -- paper - b : # 1 b1611 ;
-- ink : # ede4d3 ; -- ink - rgb : 237 , 228 , 211 ;
-- amber : # e8b45a ; -- amber - rgb : 232 , 180 , 90 ;
-- ok : # 7 fe0a8 ; -- ok - rgb : 63 , 190 , 120 ;
-- err : # e58575 ; -- err - rgb : 217 , 95 , 76 ;
-- track : rgba ( 255 , 255 , 255 , . 08 ) ;
}
html , body { margin : 0 ; background : repeating - linear - gradient ( 0 deg , var ( -- paper - a ) 0 px , var ( -- paper - a ) 3 px , var ( -- paper - b ) 3 px , var ( -- paper - b ) 4 px ) ; color : var ( -- ink ) ;
font - family : - apple - system , "PingFang TC" , "Microsoft JhengHei" , system - ui , sans - serif ; font - size : 16px ; - webkit - font - smoothing : antialiased ; }
. serif { font - family : 'Songti TC' , 'LiSong Pro' , PMingLiU , serif ; }
main { max - width : 560px ; margin : 0 auto ; padding : 0 20 px 40 px ; }
. pagehead { padding : 22px 2 px 14 px ; border - bottom : 2px solid rgba ( var ( -- amber - rgb ) , . 4 ) ; display : flex ; justify - content : space - between ; align - items : baseline ; }
. pagehead . title { font - family : 'Songti TC' , 'LiSong Pro' , PMingLiU , serif ; font - size : 23px ; letter - spacing : .2em ; }
. pagehead . title small { font - size : 14px ; letter - spacing : .3em ; color : rgba ( var ( -- ink - rgb ) , . 5 ) ; }
. pagehead . date { font - family : 'Songti TC' , 'LiSong Pro' , PMingLiU , serif ; font - size : 14px ; color : rgba ( var ( -- ink - rgb ) , . 55 ) ; }
. orb - row { display : flex ; align - items : center ; gap : 20px ; padding : 26px 2 px 20 px ; }
. orb { width : 84px ; height : 84px ; border - radius : 50 % ; flex : none ; display : grid ; place - items : center ; }
. orb span { font - family : 'Songti TC' , 'LiSong Pro' , PMingLiU , serif ; font - size : 30px ; font - weight : 600 ; color : rgba ( 10 , 20 , 14 , . 85 ) ; text - shadow : 0 1 px 0 rgba ( 255 , 255 , 255 , . 25 ) ; }
. orb - title { font - family : 'Songti TC' , 'LiSong Pro' , PMingLiU , serif ; font - size : 23px ; font - weight : 600 ; }
. orb - sub { margin - top : 5px ; font - size : 15px ; color : rgba ( var ( -- ink - rgb ) , . 6 ) ; line - height : 1.55 ; }
@keyframes breatheGreen { 0 % , 100 % { box - shadow : 0 0 24 px 6 px rgba ( var ( -- ok - rgb ) , . 35 ) ; } 50 % { box - shadow : 0 0 42 px 14 px rgba ( var ( -- ok - rgb ) , . 55 ) ; } }
@keyframes breatheAmber { 0 % , 100 % { box - shadow : 0 0 24 px 6 px rgba ( var ( -- amber - rgb ) , . 35 ) ; } 50 % { box - shadow : 0 0 42 px 14 px rgba ( var ( -- amber - rgb ) , . 6 ) ; } }
@keyframes breatheRed { 0 % , 100 % { box - shadow : 0 0 24 px 6 px rgba ( var ( -- err - rgb ) , . 4 ) ; } 50 % { box - shadow : 0 0 44 px 16 px rgba ( var ( -- err - rgb ) , . 65 ) ; } }
. bricks { display : grid ; grid - template - columns : 1fr 1 fr ; gap : 12px ; }
. brick { padding : 16px ; border - radius : 12px ; }
. brick . amber { background : rgba ( var ( -- amber - rgb ) , . 07 ) ; border : 1px solid rgba ( var ( -- amber - rgb ) , . 22 ) ; }
. brick . plain { background : rgba ( var ( -- ink - rgb ) , . 04 ) ; border : 1px solid rgba ( var ( -- ink - rgb ) , . 14 ) ; }
. brick . lbl { font - size : 13.5px ; color : rgba ( var ( -- ink - rgb ) , . 55 ) ; margin - bottom : 6px ; }
. brick . num { font - family : ui - monospace , Menlo , monospace ; font - size : 26px ; color : var ( -- amber ) ; }
. brick . num small { font - size : 15px ; color : rgba ( var ( -- ink - rgb ) , . 5 ) ; }
. bar { margin - top : 10px ; height : 6px ; border - radius : 3px ; background : var ( -- track ) ; }
. bar > i { display : block ; height : 100 % ; border - radius : 3px ; background : linear - gradient ( 90 deg , # b98330 , # e8b45a ) ; transition : width . 6 s ; }
. wait - box { margin - top : 14px ; padding : 20px ; border - radius : 12px ; border : 1px dashed rgba ( var ( -- ok - rgb ) , . 3 ) ; background : rgba ( var ( -- ok - rgb ) , . 05 ) ; }
. wait - box . has { border - color : rgba ( var ( -- amber - rgb ) , . 45 ) ; background : rgba ( var ( -- amber - rgb ) , . 05 ) ; }
. wait - head { font - family : 'Songti TC' , 'LiSong Pro' , PMingLiU , serif ; font - size : 16px ; letter - spacing : .2em ; color : rgba ( var ( -- ink - rgb ) , . 6 ) ; margin - bottom : 10px ; 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 14 px ; 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 - 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 10 px ; }
. 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 ) ; }
ul . route { list - style : none ; margin : 0 ; padding : 0 ; display : flex ; flex - direction : column ; gap : 8px ; }
ul . route li { display : flex ; align - items : flex - start ; gap : 12px ; padding : 13px 16 px ; border - radius : 11px ; background : rgba ( var ( -- ink - rgb ) , . 045 ) ; border : 1px solid transparent ; font - size : 16px ; line - height : 1.4 ; }
ul . route li . doing { background : rgba ( var ( -- amber - rgb ) , . 09 ) ; border - color : rgba ( var ( -- amber - rgb ) , . 3 ) ; }
ul . route li . ic { flex : none ; font - size : 15px ; margin - top : 2px ; }
ul . route li . done { color : rgba ( var ( -- ink - rgb ) , . 65 ) ; }
ul . route li . done . ic { color : var ( -- ok ) ; }
ul . route li . doing . ic { color : var ( -- amber ) ; }
ul . route li . todo { color : rgba ( var ( -- ink - rgb ) , . 6 ) ; }
ul . route li . todo . ic { color : rgba ( var ( -- ink - rgb ) , . 35 ) ; }
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 14 px ; 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 ; }
. err { color : var ( -- err ) ; font - size : 14px ; }
. stamp { margin : 16px 0 8 px ; text - align : center ; font - size : 12.5px ; color : rgba ( var ( -- ink - rgb ) , . 35 ) ; line - height : 1.8 ; }
. enter { display : block ; text - align : center ; font - size : 13.5px ; color : rgba ( var ( -- amber - rgb ) , . 75 ) ; text - decoration : none ; margin - top : 6px ; }
. theme - btn { flex : none ; margin - left : 12px ; width : 34px ; height : 34px ; border - radius : 50 % ; border : 1px solid rgba ( var ( -- ink - rgb ) , . 25 ) ; background : none ; color : rgba ( var ( -- ink - rgb ) , . 65 ) ; font - size : 16px ; cursor : pointer ; line - height : 1 ; align - self : center ; }
< / style >
< / head >
< body >
< main >
< div class = "pagehead" >
< div class = "title serif" > $ { brand } < small > 駕 駛 艙 < / small > < / div >
< div style = "display:flex;align-items:baseline" >
< div class = "date serif" id = "date-str" > < / div >
< button class = "theme-btn" id = "theme-btn" title = "切換深/淺色" > ☾ < / button >
< / div >
< / div >
< div class = "orb-row" >
< div class = "orb" id = "orb" style = "background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)" > < span id = "orb-char" > … < / span > < / div >
< div >
< div class = "orb-title" id = "orb-title" > 載 入 中 < / div >
< div class = "orb-sub" id = "orb-sub" > < / div >
< / div >
< / div >
< div class = "bricks" >
< div class = "brick amber" >
< div class = "lbl" > 今 日 完 成 < / div >
< div class = "num" > < span id = "done-n" > – < / span > < small > / < span id = "total-n" > – < / span > 件 < / small > < / div >
< div class = "bar" > < i id = "bar-fill" style = "width:0%" > < / i > < / div >
< / div >
< div class = "brick plain" >
< div class = "lbl" > 收 件 匣 未 處 理 < / div >
< div class = "num" > < span id = "inbox-n" > – < / span > < small > 條 < / small > < / div >
< div class = "lbl" style = "margin:10px 0 0" > 來 自 Telegram < / div >
< / div >
< / div >
< div class = "wait-box" id = "wait-box" >
< div class = "wait-head" > 等 你 的 事 < / div >
< div id = "wait-body" class = "wait-none" > 載 入 中 … < / div >
< div class = "wait-meta" id = "wait-meta" > < / div >
< / 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 >
< div class = "subhead" id = "week-head" style = "display:none" > < span class = "t" > 本 週 < / span > < / div >
< 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 >
< a class = "enter" href = "/console" > 進 入 完 整 控 制 台 › < / a >
< / main >
< script >
( function ( ) {
// 台北時間 helper( lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
$ { TAIPEI_CLIENT_JS }
const $ = ( id ) = > document . getElementById ( id ) ;
const LIGHT = {
green : { ch : '安' , title : '系統運轉中' , grad : 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)' , anim : 'breatheGreen' } ,
yellow : { ch : '趕' , title : '落後趕工中' , grad : 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)' , anim : 'breatheAmber' } ,
red : { ch : '滯' , title : '卡住或斷訊' , grad : 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)' , anim : 'breatheRed' }
} ;
const ICONS = { done : '✓' , doing : '◐' , todo : '○' , blocked : '●' } ;
function esc ( s ) {
return String ( s ? ? '' ) . replace ( /[&<>"']/g , ( c ) = > ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
}
function taskLine ( t ) {
const cls = ICONS [ t . status ] ? t . status : 'blocked' ;
const ic = ICONS [ t . status ] || '●' ;
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 = [ '零' , '一' , '二' , '三' , '四' , '五' , '六' , '七' , '八' , '九' , '十' ] ;
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 nowTpe = taipeiMonthDay ( Date . now ( ) ) ;
$ ( 'date-str' ) . textContent = CNUM [ nowTpe . month ] + '月' + cnDay ( nowTpe . day ) + '日' ;
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
function syncThemeBtn() { $ ( 'theme-btn' ) . textContent = document . documentElement . getAttribute ( 'data-theme' ) === 'dark' ? '☀' : '☾' ; }
$ ( 'theme-btn' ) . addEventListener ( 'click' , ( ) = > {
const next = document . documentElement . getAttribute ( 'data-theme' ) === 'dark' ? 'light' : 'dark' ;
document . documentElement . setAttribute ( 'data-theme' , next ) ;
try { localStorage . setItem ( 'arcrun_console_theme' , next ) ; } catch ( e ) { /* 私密模式忽略 */ }
syncThemeBtn ( ) ;
} ) ;
syncThemeBtn ( ) ;
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
function friendlyErr ( 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 ;
}
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() {
try {
const res = await fetch ( '/console/dashboard-data' ) ;
if ( ! res . ok ) throw new Error ( 'HTTP ' + res . status ) ;
const d = await res . json ( ) ;
const cfg = LIGHT [ d . light ] || LIGHT . green ;
const orb = $ ( 'orb' ) ;
orb . style . background = cfg . grad ;
orb . style . animation = cfg . anim + ' 3.4s ease-in-out infinite' ;
$ ( 'orb-char' ) . textContent = cfg . ch ;
$ ( 'orb-title' ) . textContent = cfg . title ;
$ ( 'orb-sub' ) . textContent = ( d . last_beat
? 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 ;
$ ( 'done-n' ) . textContent = done ; $ ( 'total-n' ) . textContent = total ;
$ ( 'bar-fill' ) . style . width = ( total ? Math . round ( ( done / total ) * 100 ) : 0 ) + '%' ;
$ ( 'inbox-n' ) . textContent = d . inbox_new || 0 ;
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
const wb = $ ( 'wait-box' ) , body = $ ( 'wait-body' ) , wmeta = $ ( 'wait-meta' ) ;
const wm = d . waiting_meta || { } ;
if ( d . waiting && d . waiting . length ) {
wb . classList . add ( 'has' ) ;
body . className = '' ;
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 {
wb . classList . remove ( 'has' ) ;
body . className = 'wait-none' ;
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 ;
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
const rm = d . route_meta || { } ;
const today = ( d . tasks || [ ] ) . filter ( ( t ) = > t . scope === 'today' ) ;
const week = ( d . tasks || [ ] ) . filter ( ( t ) = > t . scope === 'week' ) ;
if ( rm . source === 'gitea_sprint_board' ) {
$ ( 'route-m' ) . textContent = '來源 sprint 任務板・更新於 ' + humanAge ( rm . updated_ago_minutes ) ;
$ ( 'today-list' ) . className = 'route' ;
const staleHead = rm . is_today ? '' :
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge ( rm . updated_ago_minutes ) + ')——以下是板上現況</span></li>' ;
$ ( 'today-list' ) . innerHTML = staleHead + ( today . length
? today . map ( taskLine ) . join ( '' )
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>' ) ;
} else 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-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' : '' ) ) ;
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
rows . push ( sysRow ( '精耕層 wiki 卡' , kb . wiki_card_total == null ? '讀不到' : kb . wiki_card_total + ' 張' , kb . wiki_card_total == null ? 'off' : '' ) ) ;
$ ( 'sys-list' ) . innerHTML = rows . join ( '' ) ;
$ ( 'stamp' ) . innerHTML = '每 60 秒自動刷新・上次 ' + esc ( taipeiTimeStr ( Date . parse ( d . generated_at ) ) ) + '(台北)<br>此頁不含機敏內容,免登入' ;
} catch ( e ) {
$ ( 'orb-char' ) . textContent = '? ' ;
$ ( 'orb-title' ) . textContent = '讀不到狀態' ;
$ ( 'orb-sub' ) . innerHTML = '<span class="err">' + esc ( friendlyErr ( e ) ) + '・每 60 秒自動重試</span>' ;
}
}
load ( ) ;
setInterval ( load , 60000 ) ;
} ) ( ) ;
< / script >
< / body >
< / html >
` ;
}
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG")
// CONSOLE_PROFILE=rag( console-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。
// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。
consoleDashboardRouter . get ( '/console/dashboard' , ( c ) = > {
if ( ( c . env . CONSOLE_PROFILE || 'full' ) === 'rag' ) return c . redirect ( '/console' , 302 ) ;
return c . html ( renderDashboardHtml ( c . env . CONSOLE_BRAND || 'Arcrun' ) ) ;
} ) ;