Files
Arcrun/cypher-executor/src/routes/console-dashboard.ts
T

823 lines
46 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
* 2026-07-07 fix/console-dashboard-live-datastale 資料整修,總管交辦)
*
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* - 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/statusenabled: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 走 HTTPkbdbBase 慣例),不新增 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_URLvar+ GITEA_TOKENsecret,建議唯讀 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 的板先列);兩檔都沒有可解析的板 → nullfallback dash_task
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
// 清單上次維護時間 = 現役 sprint 檔最後 commitprogress-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 APIcaches.default)、
* TTL 90sGITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
*
* - key:合成 URLCache 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/missfallback 路徑為 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_atlist 為 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,預設 259200030 天);
// 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 頁 7Arcrun#9 收件夾改裝;原
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
// **需 console session**Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
// 資料源二合一(kb-ingest SDD R7):entry_type=todoLogseq 萃取,Arcrun#8 ingest 線)+
// entry_type=inboxTelegram)。契約解析/三欄分流/計數=純函式 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_TOKENtoken 只能在
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
// triage-data 同款 session 驗證,server 端做 KBDB PATCHkbdbBase 慣例)。
//
// 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:donechecked_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 () {
// 台北時間 helperlib/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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=ragconsole-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'));
});