@@ -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(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記,
* 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 後前一檔常還有未銷案項/未收項,例:07b 開了、
* 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 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 90s( GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
*
* - 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>
/* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。
2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */
* { box-sizing: border-box; }
:root {
--paper-a: #f4eddc; --paper-b: #f1e9d6;
--ink: #2f2a20; --ink-rgb: 30,24,14;
--amber: #8a5f1e; --amber-rgb: 138,95,30;
--ok: #1d7a48; --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: #1b1611;
--ink: #ede4d3; --ink-rgb: 237,228,211;
--amber: #e8b45a; --amber-rgb: 232,180,90;
--ok: #7fe0a8; --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(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); 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 20px 40px; }
.pagehead { padding: 22px 2px 14px; 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 2px 20px; }
.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 1px 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 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }
.bricks { display: grid; grid-template-columns: 1fr 1fr; 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(90deg,#b98330,#e8b45a); transition: width .6s; }
.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 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-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 .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 16px; 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 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; }
.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; }
.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' ) ) ;
} ) ;