feat: add unified OA event flow

This commit is contained in:
Codex
2026-05-14 09:41:55 +00:00
parent b36d7f94d7
commit 9f483b002c
37 changed files with 3417 additions and 502 deletions
File diff suppressed because one or more lines are too long
+196 -5
View File
@@ -1422,7 +1422,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.result-card dd { margin: 0; }
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page {
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page {
display: grid;
gap: 10px;
}
@@ -1464,6 +1464,130 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
gap: 10px;
align-items: start;
}
.oa-flow-hero {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(260px, 0.45fr);
gap: 10px;
align-items: stretch;
}
.oa-flow-signal {
position: relative;
display: grid;
gap: 4px;
min-width: 0;
padding: 10px;
overflow: hidden;
border: 1px solid rgba(78, 183, 168, 0.28);
background:
linear-gradient(120deg, rgba(78, 183, 168, 0.14), rgba(215, 161, 58, 0.08)),
repeating-linear-gradient(90deg, rgba(255,255,255,0.04) 0, rgba(255,255,255,0.04) 1px, transparent 1px, transparent 14px),
var(--panel-3);
}
.oa-flow-signal::after {
content: "";
position: absolute;
inset: auto -20% 0 -20%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-2), var(--accent), transparent);
animation: oa-signal-scan 2.8s linear infinite;
}
.oa-flow-signal span {
color: var(--accent);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.oa-flow-signal strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
}
.oa-flow-signal code { color: var(--muted); }
@keyframes oa-signal-scan {
from { transform: translateX(-40%); opacity: 0.35; }
50% { opacity: 1; }
to { transform: translateX(40%); opacity: 0.35; }
}
.oa-flow-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.oa-filter-bar {
display: grid;
grid-template-columns: minmax(280px, 0.8fr) auto minmax(180px, 0.45fr);
gap: 10px;
align-items: end;
}
.oa-filter-bar label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.oa-filter-bar input {
width: 100%;
min-height: 34px;
padding: 7px 9px;
border: 1px solid var(--line);
color: var(--text);
background: var(--panel-3);
}
.oa-filter-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.oa-filter-bar > code {
align-self: center;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-2);
}
.oa-type-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.oa-flow-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.oa-flow-wide { min-width: 0; }
.oa-event-table, .oa-stats-table {
min-width: 1120px;
font-size: 12px;
}
.oa-event-table td,
.oa-stats-table td {
vertical-align: top;
}
.oa-tag-rail {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 360px;
}
.oa-tag-rail code {
padding: 2px 5px;
border: 1px solid rgba(78, 183, 168, 0.22);
color: var(--accent-2);
background: rgba(78, 183, 168, 0.07);
}
.oa-payload-preview {
display: block;
max-width: 360px;
color: var(--muted);
overflow-wrap: anywhere;
}
.findjob-grid .panel:nth-child(3) { grid-column: 1 / -1; }
.claudeqq-page .findjob-grid .panel:nth-child(n+3) { grid-column: 1 / -1; }
.filebrowser-hero {
@@ -2356,7 +2480,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
min-width: 0;
align-items: center;
}
.codex-submit-queue-row select {
.codex-submit-queue-select {
grid-area: queue-select;
}
.codex-rename-queue-btn,
@@ -2376,6 +2500,49 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.codex-merge-queue-btn {
grid-area: merge;
}
.codex-merge-dialog {
width: min(480px, 96vw);
overflow: hidden;
border-color: rgba(78, 183, 168, 0.34);
border-radius: 18px;
background:
radial-gradient(circle at 20% 0%, rgba(78, 183, 168, 0.16), transparent 34%),
#0a1015;
}
.codex-merge-dialog-body {
display: grid;
gap: 12px;
padding: 14px;
}
.codex-merge-dialog-body label {
display: grid;
gap: 6px;
color: rgba(226, 244, 244, 0.82);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.codex-merge-dialog-body select {
width: 100%;
min-height: 40px;
box-sizing: border-box;
}
.codex-merge-dialog-target,
.codex-merge-dialog-note {
margin: 0;
color: rgba(217, 238, 238, 0.76);
line-height: 1.55;
}
.codex-merge-dialog-target code {
color: #eafffb;
}
.codex-merge-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.codex-reference-field {
display: grid;
gap: 5px;
@@ -5090,6 +5257,29 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
padding: 10px;
border-bottom: 1px solid var(--line);
}
.unidesk-dialog {
overflow: hidden;
}
.unidesk-dialog-body {
display: grid;
gap: 10px;
max-height: calc(86vh - 118px);
overflow: auto;
padding: 10px;
}
.unidesk-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
padding: 10px;
border-top: 1px solid var(--line);
background: rgba(255,255,255,0.02);
}
.unidesk-dialog-body.codex-merge-dialog-body {
gap: 12px;
padding: 14px;
}
.raw-json {
max-height: calc(86vh - 58px);
overflow: auto;
@@ -5168,11 +5358,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
@media (max-width: 1120px) {
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .performance-metric-stack, .codex-load-test-grid, .baidu-doc-grid, .filebrowser-target-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .performance-metric-stack, .codex-load-test-grid, .baidu-doc-grid, .filebrowser-target-grid, .oa-flow-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pipeline-oa-guarantees { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dispatch-form, .schedule-form { grid-template-columns: 1fr 1fr; }
.dispatch-actions { align-items: center; }
.page-grid, .docker-layout, .monitor-layout, .performance-top-grid, .performance-grid, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .code-queue-layout, .code-queue-hero, .codex-detail-grid, .project-manager-hero, .project-manager-layout, .baidu-netdisk-grid, .baidu-netdisk-hero, .baidu-transfer-forms, .filebrowser-hero { grid-template-columns: 1fr; }
.page-grid, .docker-layout, .monitor-layout, .performance-top-grid, .performance-grid, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .code-queue-layout, .code-queue-hero, .codex-detail-grid, .project-manager-hero, .project-manager-layout, .baidu-netdisk-grid, .baidu-netdisk-hero, .baidu-transfer-forms, .filebrowser-hero, .oa-flow-hero, .oa-filter-bar { grid-template-columns: 1fr; }
.codex-session-shell { grid-template-columns: minmax(260px, 0.42fr) minmax(0, 1fr); position: relative; }
.codex-session-shell.queue-collapsed { grid-template-columns: minmax(0, 1fr); }
.codex-session-sidebar { border-right: 1px solid var(--line); border-bottom: 0; }
@@ -5198,7 +5388,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.pipeline-kv-grid,
.pipeline-field-list,
.performance-metric-stack,
.codex-load-test-grid {
.codex-load-test-grid,
.oa-flow-metrics {
grid-template-columns: 1fr;
}
.performance-hero { flex-direction: column; }
+3
View File
@@ -8,6 +8,7 @@ import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
import { OaEventFlowPage } from "./oa-event-flow";
import { PipelinePage } from "./pipeline";
import { ProjectManagerPage } from "./project-manager";
import { TodoNotePage } from "./todo-note";
@@ -1638,6 +1639,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null,
service.id === "claudeqq" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "claudeqq"), "data-testid": "open-claudeqq-button" }, "打开") : null,
service.id === "baidu-netdisk" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "baidu-netdisk"), "data-testid": "open-baidu-netdisk-button" }, "打开") : null,
service.id === "oa-event-flow" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "oa-event-flow"), "data-testid": "open-oa-event-flow-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
@@ -2143,6 +2145,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "claudeqq") return h(ClaudeQqPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "baidu-netdisk") return h(BaiduNetdiskPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "filebrowser") return h(FileBrowserPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "oa-event-flow") return h(OaEventFlowPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
-1
View File
@@ -292,7 +292,6 @@ export function ClaudeQqPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
),
),
h(UniDeskErrorBanner, { error: state.error, wide: true }),
actionMessage ? h("div", { className: "form-success wide" }, actionMessage) : null,
),
h("div", { className: "metric-grid" },
h(MetricCard, { label: "Health", value: health.ok || health.status === "ok" ? "OK" : "--", hint: "D601 /health", tone: health.ok || health.status === "ok" ? "ok" : "warn" }),
+177 -51
View File
@@ -6,6 +6,7 @@ import { TraceView, codexTracePort } from "./trace";
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
import { useNotification } from "./notification-context";
import { UniDeskDialog } from "./dialog";
type AnyRecord = Record<string, any>;
@@ -150,6 +151,10 @@ function codexApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/code-queue-direct${path}`;
}
function oaCodeQueueEventsApi(apiBaseUrl: string): string {
return `${apiBaseUrl}/microservices/oa-event-flow/proxy/api/events/stream?tags=${encodeURIComponent("service:code-queue")}`;
}
function codexNoCacheOptions(): AnyRecord {
return { headers: { "cache-control": "no-cache", "x-unidesk-no-cache": "1" } };
}
@@ -572,17 +577,43 @@ function nonNegativeCount(value: any): number {
return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
}
function canonicalTaskStepCount(task: any): number {
const summary = taskTraceSummary(task);
if (summary !== null) return traceSummaryStepCount(summary);
const value = Number(task?.stepCount ?? task?.llmStepCount ?? 0);
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
function statCount(value: any): number | null {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : null;
}
function traceSummaryStepCount(summary: AnyRecord | null): number {
const execution = objectRecord(summary?.execution) || {};
const value = Number(summary?.stepCount ?? summary?.llmStepCount ?? execution.stepCount ?? execution.llmStepCount ?? execution.toolCallCount ?? 0);
return nonNegativeCount(value);
function taskOaTraceStats(task: any): AnyRecord | null {
const direct = objectRecord(task?.traceStats);
if (direct && (task?.statsSource === "oa-event-flow" || direct.source === "oa-event-flow")) return direct;
const summary = taskTraceSummary(task);
const summaryStats = objectRecord(summary?.traceStats);
if (summaryStats && (summary?.statsSource === "oa-event-flow" || summaryStats.source === "oa-event-flow")) return summaryStats;
return null;
}
function attemptOaTraceStats(task: any, attempt: any): AnyRecord | null {
const direct = objectRecord(attempt?.traceStats) || objectRecord(attempt?.execution?.traceStats);
const source = String(attempt?.statsSource || attempt?.execution?.statsSource || "");
if (direct && (source === "oa-event-flow" || direct.source === "oa-event-flow")) return direct;
return null;
}
function traceStatCount(task: any, key: string): number | null {
return statCount(taskOaTraceStats(task)?.[key]);
}
function canonicalTaskStepCount(task: any): number | null {
const direct = traceStatCount(task, "stepCount");
if (direct !== null) return direct;
const summary = taskTraceSummary(task);
if (summary !== null) return traceSummaryStepCount(summary);
return null;
}
function traceSummaryStepCount(summary: AnyRecord | null): number | null {
const stats = objectRecord(summary?.traceStats);
if (!stats || (summary?.statsSource !== "oa-event-flow" && stats.source !== "oa-event-flow")) return null;
return statCount(stats.stepCount);
}
function traceSummaryIsCurrent(task: any): boolean {
@@ -853,8 +884,7 @@ function coalesceFileChangeTraceSteps(steps: any[]): any[] {
}
function canonicalExecutionSummary(execution: AnyRecord): AnyRecord {
const stepCount = nonNegativeCount(execution?.stepCount ?? execution?.llmStepCount ?? execution?.toolCallCount);
return { ...execution, stepCount, llmStepCount: stepCount };
return { ...execution };
}
function taskTraceStepsLoaded(task: any, attemptIndex: any = null): boolean {
@@ -1209,7 +1239,7 @@ function providerDefaultWorkdir(queue: any, providerId: string): string {
return id === mainProvider ? String(queue?.defaultWorkdir || "/root/unidesk") : String(queue?.remoteDefaultWorkdir || "/home/ubuntu");
}
function taskStepCount(task: any): number {
function taskStepCount(task: any): number | null {
return canonicalTaskStepCount(task);
}
@@ -1220,6 +1250,8 @@ function TaskCard({ task, selected, onSelect, onCopy, onReference, onMarkRead, c
const updatedAt = latestTimestampValue(task?.updatedAt, taskTraceSummary(task)?.updatedAt);
const recentUpdateLabel = `最近更新: ${fmtRelativeAge(updatedAt)}`;
const stepCount = taskStepCount(task);
const stepLabel = stepCount === null ? "--" : String(stepCount);
const stepTitle = stepCount === null ? "STEP 统计中心同步中" : "STEP 来自 OA Event Flow 统计中心";
return h("article", {
role: "button",
tabIndex: 0,
@@ -1282,7 +1314,7 @@ function TaskCard({ task, selected, onSelect, onCopy, onReference, onMarkRead, c
h("span", null, taskDurationLabel(task)),
),
h("div", { className: "codex-task-meta codex-task-update-meta" },
h("span", { className: "codex-task-recent-update codex-task-step-count", title: "STEP 按 read/edit/run 工具动作统计", "data-testid": `codex-task-step-count-${taskId || "unknown"}` }, `STEP ${stepCount}`),
h("span", { className: "codex-task-recent-update codex-task-step-count", title: stepTitle, "data-testid": `codex-task-step-count-${taskId || "unknown"}` }, `STEP ${stepLabel}`),
h("span", { className: "codex-task-recent-update", title: updatedAt ? `更新时间: ${fmtDate(updatedAt)}` : recentUpdateLabel, "data-testid": `codex-task-recent-update-${taskId || "unknown"}` }, recentUpdateLabel),
h("span", null, fmtDate(updatedAt || task?.updatedAt)),
),
@@ -1537,12 +1569,17 @@ function ProgressivePromptBlock({ task, loading, onLoadPromptPart, testId = "cod
function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onLoadSteps, onLoadStep, testId = "codex-execution-summary" }: AnyRecord) {
const steps = coalesceFileChangeTraceSteps(taskTraceSteps(task, attemptIndex));
const execution = canonicalExecutionSummary(attemptExecutionSummary(task, attempt));
const summary = taskTraceSummary(task);
const stats = attempt ? attemptOaTraceStats(task, attempt) : taskOaTraceStats(task);
const stepDetails = taskTraceStepDetails(task);
const stepsLoaded = taskTraceStepsLoaded(task, attemptIndex);
const errorCount = Number(attempt?.errorCount ?? summary?.errorCount ?? traceStepErrorCount(steps));
const toolCount = nonNegativeCount(execution.toolCallCount);
const stepCount = nonNegativeCount(execution.stepCount ?? execution.llmStepCount ?? execution.toolCallCount);
const errorCount = statCount(stats?.errorCount);
const toolCount = statCount(stats?.stepCount ?? stats?.llmStepCount);
const readCount = statCount(stats?.readCount);
const editCount = statCount(stats?.editCount);
const runCount = statCount(stats?.runCount);
const stepCount = statCount(stats?.stepCount ?? stats?.llmStepCount);
const toolsLabel = toolCount === null ? "--" : String(toolCount);
const stepLabel = stepCount === null ? "--" : String(stepCount);
const editedFiles = Array.isArray(execution.editedFiles) ? execution.editedFiles : [];
const commands = Array.isArray(execution.commands) ? execution.commands : [];
const synthetic = isSyntheticAttemptSegment(attempt, attemptIndex);
@@ -1565,14 +1602,14 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
h("strong", null, `执行过程摘要${labelSuffix}`),
running ? h("span", { className: "codex-summary-running-pill", "data-testid": `${testId}-running` }, "执行中") : null,
h("code", { title: updatedAt ? `最近更新: ${fmtDate(updatedAt)}` : recentUpdateLabel },
`${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolCount} tools / ${recentUpdateLabel}`),
`${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolsLabel} tools / ${recentUpdateLabel}`),
),
h("div", { className: "codex-execution-digest" },
h("span", null, `read ${Number(execution.readCount || 0)}`),
h("span", null, `edit ${Number(execution.editCount || 0)}`),
h("span", null, `run ${Number(execution.runCount || 0)}`),
h("span", null, `STEP ${stepCount}`),
errorCount > 0 ? h("span", { className: "codex-execution-error-pill", "data-testid": `${testId}-error-count` }, `Error ${errorCount}`) : null,
h("span", { title: "来自 OA Event Flow 统计中心" }, `read ${readCount === null ? "--" : readCount}`),
h("span", { title: "来自 OA Event Flow 统计中心" }, `edit ${editCount === null ? "--" : editCount}`),
h("span", { title: "来自 OA Event Flow 统计中心" }, `run ${runCount === null ? "--" : runCount}`),
h("span", { title: "来自 OA Event Flow 统计中心" }, `STEP ${stepLabel}`),
errorCount !== null && errorCount > 0 ? h("span", { className: "codex-execution-error-pill", "data-testid": `${testId}-error-count` }, `Error ${errorCount}`) : null,
),
),
h("div", { className: "codex-execution-digest expanded" },
@@ -1849,6 +1886,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const [referenceTaskId, setReferenceTaskId] = useState("");
const [queueId, setQueueId] = useState("default");
const [selectedQueueId, setSelectedQueueId] = useState(allQueuesId);
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeSourceQueueId, setMergeSourceQueueId] = useState("");
const [providerId, setProviderId] = useState("main-server");
const [model, setModel] = useState("gpt-5.5");
const [cwd, setCwd] = useState("/root/unidesk");
@@ -1888,6 +1927,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const statistics = taskStatistics(tasksData, queue);
const pagination = taskPagination(tasksData);
const queueRows = knownQueueRows(queue, queueId);
const mergeTargetQueueId = String(queueId || "default").trim() || "default";
const mergeSourceQueueRows = queueRows.filter((row: any) => String(row?.id || "") !== mergeTargetQueueId);
const viewQueueRow = selectedQueueRow(queueRows, selectedQueueId);
const totalTaskCount = Number((isAllQueues(selectedQueueId) ? queue?.total : viewQueueRow?.total) ?? pagination.total ?? tasks.length);
const globalActiveIds = activeTaskIds(queue);
@@ -2138,6 +2179,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
attempts: Array.isArray(summary.attempts) ? summary.attempts : [],
stepCount: summary.stepCount,
llmStepCount: summary.llmStepCount,
traceStats: summary.traceStats,
statsSource: summary.statsSource,
timing: summary.timing,
_traceSummary: summary,
_traceSummaryLoaded: true,
@@ -2758,25 +2801,36 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
}, "修改 Codex queue 名称失败");
}
async function mergeQueue(): Promise<void> {
const targetQueueId = String(queueId || "default").trim() || "default";
const candidates = queueRows.filter((row: any) => String(row?.id || "") !== targetQueueId);
if (candidates.length === 0) {
function openMergeQueueDialog(): void {
if (mergeSourceQueueRows.length === 0) {
const msg = "没有可合并的其他 queue;请先创建或选择另一个 queue。";
setNotice(msg);
return;
}
const proposed = typeof window === "undefined"
? ""
: window.prompt(`将源 queue 合并到当前 queue=${targetQueueId};源 queue 记录会保留且不会删除。请输入源 queue ID:`, String(candidates[0]?.id || ""));
const existingSource = mergeSourceQueueRows.some((row: any) => String(row?.id || "") === mergeSourceQueueId)
? mergeSourceQueueId
: "";
setMergeSourceQueueId(existingSource);
setMergeDialogOpen(true);
}
async function mergeQueue(): Promise<void> {
const targetQueueId = mergeTargetQueueId;
if (mergeSourceQueueRows.length === 0) {
const msg = "没有可合并的其他 queue;请先创建或选择另一个 queue。";
setNotice(msg);
return;
}
const proposed = mergeSourceQueueId || "";
const sourceQueueId = String(proposed || "").trim();
if (!sourceQueueId) return;
if (!sourceQueueId) {
setNotice("请先选择要合并的源 queue。");
return;
}
if (sourceQueueId === targetQueueId) {
setError("源 queue 和目标 queue 不能相同。");
return;
}
const confirmed = typeof window === "undefined" ? true : window.confirm(`确认把 queue=${sourceQueueId} 的全部任务合并到 queue=${targetQueueId}?源 queue 不会删除;任务会按原 queueEnteredAt/createdAt 时间顺序进入目标 queue。`);
if (!confirmed) return;
await guarded(async () => {
const result = await requestJson(codexApi(apiBaseUrl, `/api/queues/${encodeURIComponent(targetQueueId)}/merge`), { method: "POST", body: { sourceQueueId } });
if (result?.summary) {
@@ -2786,9 +2840,11 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
setSelectedQueueId(targetQueueId);
setTasksData(null);
const count = Number(result?.mergedTaskCount || 0);
const msg = `已将 queue=${sourceQueueId} 合并到 ${targetQueueId},移动 ${count} 个任务;源 queue 已保留`;
const msg = `已将 queue=${sourceQueueId} 合并到 ${targetQueueId},移动 ${count} 个任务;源 queue 已自动删除`;
setNotice(msg);
addNotification("success", msg);
setMergeDialogOpen(false);
setMergeSourceQueueId("");
await load(selectedIdRef.current, true, targetQueueId);
}, "合并 Codex queue 失败");
}
@@ -2995,11 +3051,12 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
function taskEventNeedsOverviewRefresh(event: AnyRecord, taskId: string, previousTask: any): boolean {
const type = String(event?.type || "");
if (type === "queue-updated") return true;
if (type === "trace-stats-updated" || type === "trace-step-created") return false;
if (taskId.length === 0) return true;
if (!previousTask) return type !== "task-step";
if (!previousTask) return true;
if (event?.queueId && String(event.queueId) !== taskQueueLabel(previousTask)) return true;
if (event?.status && String(event.status) !== String(previousTask?.status || "")) return true;
return type !== "task-step" && String(event?.reason || "") !== "output";
return String(event?.reason || "") !== "output";
}
function scheduleEventDrivenOverviewRefresh(): void {
@@ -3026,30 +3083,72 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
}, 250);
}
function normalizeOaCodeQueueEvent(event: AnyRecord): AnyRecord {
const payload = objectRecord(event?.payload) || event;
const stats = objectRecord(payload?.stats);
const type = String(event?.type || payload?.type || "");
const subjectKind = String(payload?.subjectKind || stats?.subjectKind || "");
const scopeId = String(payload?.scopeId || stats?.scopeId || "");
const taskId = String(payload?.taskId || (subjectKind === "task" ? payload?.subjectId : "") || stats?.taskId || event?.taskId || "");
const attemptValue = payload?.attemptIndex ?? stats?.attemptIndex;
const attemptIndex = attemptValue === null || attemptValue === undefined || attemptValue === "" ? null : statCount(attemptValue);
const traceStats = stats || objectRecord(payload?.traceStats);
const stepCount = traceStats?.stepCount ?? traceStats?.llmStepCount;
const outputMaxSeq = traceStats?.outputMaxSeq;
return {
...payload,
type,
eventId: event?.eventId || payload?.eventId,
sequence: event?.sequence ?? payload?.sequence,
taskId,
subjectKind,
scopeId,
attemptIndex,
stepCount,
outputMaxSeq,
updatedAt: traceStats?.updatedAt || payload?.updatedAt || event?.createdAt,
traceStats,
statsSource: traceStats ? "oa-event-flow" : payload?.statsSource,
};
}
function applyCodeQueueEvent(event: AnyRecord): void {
const taskId = String(event?.taskId || "");
const stepCount = nonNegativeCount(event?.stepCount);
const normalizedEvent = normalizeOaCodeQueueEvent(event);
const taskId = String(normalizedEvent?.taskId || "");
const scopeId = String(normalizedEvent?.scopeId || normalizedEvent?.traceStats?.scopeId || "");
const isAttemptStats = String(normalizedEvent?.subjectKind || normalizedEvent?.traceStats?.subjectKind || "") === "task-attempt"
|| Number(normalizedEvent?.attemptIndex) > 0
|| /:attempt:\d+$/u.test(scopeId);
const stepCount = nonNegativeCount(normalizedEvent?.stepCount);
const patch: AnyRecord = {};
const previousTask = taskId.length > 0
? taskRows(tasksDataRef.current).find((task: any) => String(task?.id || "") === taskId)
: null;
if (taskId.length > 0) {
if (event?.status) patch.status = String(event.status);
if (event?.updatedAt) patch.updatedAt = String(event.updatedAt);
if (event?.queueId) patch.queueId = String(event.queueId);
if (Number.isFinite(Number(event?.stepCount))) {
if (normalizedEvent?.status) patch.status = String(normalizedEvent.status);
if (normalizedEvent?.updatedAt) patch.updatedAt = String(normalizedEvent.updatedAt);
if (normalizedEvent?.queueId) patch.queueId = String(normalizedEvent.queueId);
if (!isAttemptStats && Number.isFinite(Number(normalizedEvent?.stepCount))) {
patch.stepCount = stepCount;
patch.llmStepCount = stepCount;
}
if (!isAttemptStats && Number.isFinite(Number(normalizedEvent?.outputMaxSeq))) patch.outputMaxSeq = nonNegativeCount(normalizedEvent.outputMaxSeq);
if (!isAttemptStats && objectRecord(normalizedEvent?.traceStats)) {
patch.traceStats = normalizedEvent.traceStats;
patch.statsSource = "oa-event-flow";
}
if (Object.keys(patch).length > 0 && (sessionCacheRef.current.has(taskId) || selectedIdRef.current === taskId)) {
publishCachedTask(taskId, patch, detailLoadTokenRef.current);
}
if (Object.keys(patch).length > 0) patchSidebarTaskFromEvent(taskId, patch);
if (selectedIdRef.current === taskId && (event?.type === "task-step" || event?.type === "task-updated")) {
scheduleSelectedTraceEventRefresh(taskId, event?.type === "task-step");
if (selectedIdRef.current === taskId && (normalizedEvent?.type === "trace-step-created" || normalizedEvent?.type === "task-updated" || normalizedEvent?.type === "trace-stats-updated")) {
const previousStepCount = canonicalTaskStepCount(previousTask);
const shouldRefreshSteps = normalizedEvent?.type === "trace-step-created"
|| (normalizedEvent?.type === "trace-stats-updated" && !isAttemptStats && Number.isFinite(Number(normalizedEvent?.stepCount)) && (previousStepCount === null || stepCount > previousStepCount));
scheduleSelectedTraceEventRefresh(taskId, shouldRefreshSteps);
}
}
if (taskEventNeedsOverviewRefresh(event, taskId, previousTask)) scheduleEventDrivenOverviewRefresh();
if (taskEventNeedsOverviewRefresh(normalizedEvent, taskId, previousTask)) scheduleEventDrivenOverviewRefresh();
}
useEffect(() => {
@@ -3062,7 +3161,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
useEffect(() => {
if (!service || typeof EventSource === "undefined") return undefined;
const source = new EventSource(codexApi(apiBaseUrl, "/api/events"), { withCredentials: true });
const source = new EventSource(oaCodeQueueEventsApi(apiBaseUrl), { withCredentials: true });
const onEvent = (message: MessageEvent): void => {
try {
applyCodeQueueEvent(JSON.parse(String(message.data || "{}")));
@@ -3073,9 +3172,10 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const onVisible = (): void => {
if (isDocumentVisible()) scheduleEventDrivenOverviewRefresh();
};
source.addEventListener("task-step", onEvent);
source.addEventListener("trace-step-created", onEvent);
source.addEventListener("task-updated", onEvent);
source.addEventListener("queue-updated", onEvent);
source.addEventListener("trace-stats-updated", onEvent);
document.addEventListener("visibilitychange", onVisible);
return () => {
source.close();
@@ -3101,7 +3201,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
if (autoTraceLoadKeysRef.current.has(key)) return;
autoTraceLoadKeysRef.current.add(key);
void ensureTraceSummary(taskId, true).catch((err) => setError(errorText(err, "自动加载 Trace Summary 失败")));
}, [service?.id, selectedTask?.id, selectedTask?.updatedAt, selectedTask?.stepCount, selectedTask?.llmStepCount, selectedTask?._traceSummaryUpdatedAt, selectedTask?._traceSummaryLoaded, selectedDetailLoading]);
}, [service?.id, selectedTask?.id, selectedTask?.updatedAt, selectedTask?.traceStats?.statsRevision, selectedTask?._traceSummaryUpdatedAt, selectedTask?._traceSummaryLoaded, selectedDetailLoading]);
const taskListContent = sidebarTasks.length === 0 ? h(EmptyState, {
title: searchActive ? (searchLoading ? "搜索中" : "没有匹配任务") : "队列为空",
@@ -3261,6 +3361,32 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const loadDetailMs = Number(loadStats?.detailMs);
const loadTranscriptRows = Number(loadStats?.transcriptRows);
const loadState = loadStats?.phase === "complete" ? "complete" : String(loadStats?.phase || "idle");
const mergeTargetQueueRow = selectedQueueRow(queueRows, mergeTargetQueueId) || { id: mergeTargetQueueId, name: mergeTargetQueueId };
const mergeDialog = mergeDialogOpen ? h(UniDeskDialog, {
title: "合并 queue",
titleId: "codex-merge-dialog-title",
className: "codex-merge-dialog",
backdropClassName: "codex-merge-dialog-backdrop",
bodyClassName: "codex-merge-dialog-body",
actionsClassName: "codex-merge-dialog-actions",
testId: "codex-merge-queue-dialog",
closeTestId: "codex-merge-queue-close",
disableClose: busy,
onClose: () => setMergeDialogOpen(false),
actions: [
h("button", { key: "cancel", type: "button", className: "ghost-btn", onClick: () => setMergeDialogOpen(false), disabled: busy, "data-testid": "codex-merge-queue-cancel" }, "取消"),
h("button", { key: "confirm", type: "button", className: "primary-btn", onClick: () => void mergeQueue(), disabled: busy || submitting || !mergeSourceQueueId, "data-testid": "codex-merge-queue-confirm" }, busy ? "合并中..." : "确认合并"),
],
},
h("p", { className: "codex-merge-dialog-target" }, "目标 queue", h("code", null, queueOptionLabel(mergeTargetQueueRow))),
h("label", null, "源 queue",
h("select", { value: mergeSourceQueueId, disabled: busy || submitting, onChange: (event: any) => setMergeSourceQueueId(String(event.target.value || "")), "data-testid": "codex-merge-source-queue-select" },
h("option", { value: "" }, "选择要合并进来的源 queue"),
mergeSourceQueueRows.map((row: any) => h("option", { key: String(row?.id || ""), value: String(row?.id || "") }, queueOptionLabel(row))),
),
),
h("p", { className: "codex-merge-dialog-note" }, "会把源 queue 的任务归属合并到目标 queue,并自动删除源 queue;目标 queue 会按原 queueEnteredAt/createdAt 时间顺序运行。"),
) : null;
return h("div", {
className: `code-queue-page ${standalone ? "codex-standalone-page" : ""}`,
@@ -3274,7 +3400,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
"data-load-partial": loadStats?.partial ? "true" : "false",
},
h(UniDeskErrorBanner, { error, wide: true }),
notice ? h("div", { className: "form-success wide", "data-testid": "codex-create-success" }, notice) : null,
mergeDialog,
h("div", { className: "codex-session-stage codex-session-stage-top" },
sessionPanel,
),
@@ -3292,11 +3418,11 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
h("div", { className: "codex-form-grid" },
h("label", { className: "codex-submit-queue-field" }, "Queue",
h("div", { className: "codex-submit-queue-row" },
h("select", { value: queueId, disabled: submitting, onChange: (event: any) => setQueueId(String(event.target.value || "default")), "data-testid": "code-queue-id-select" },
h("select", { className: "codex-submit-queue-select", value: queueId, disabled: submitting, onChange: (event: any) => setQueueId(String(event.target.value || "default")), "data-testid": "code-queue-id-select" },
queueRows.map((row: any) => h("option", { key: String(row?.id || ""), value: String(row?.id || "") }, queueOptionLabel(row))),
),
h("button", { type: "button", className: "ghost-btn codex-rename-queue-btn", onClick: () => void renameQueue(), disabled: busy || submitting || !queueId, title: "修改当前 queue 的显示名称,ID 不变", "data-testid": "codex-rename-queue-button" }, "改名"),
h("button", { type: "button", className: "ghost-btn codex-merge-queue-btn", onClick: () => void mergeQueue(), disabled: busy || submitting || queueRows.length < 2, title: "把其他 queue 合并到当前 queue;源 queue 记录会保留,不会删除", "data-testid": "codex-merge-queue-button" }, "合并 queue"),
h("button", { type: "button", className: "ghost-btn codex-merge-queue-btn", onClick: () => openMergeQueueDialog(), disabled: busy || submitting || mergeSourceQueueRows.length === 0, title: "打开独立窗口选择源 queue 合并到当前 queue合并后自动删除源 queue", "data-testid": "codex-merge-queue-button" }, "合并 queue"),
h("button", { type: "button", className: "ghost-btn codex-create-queue-btn", onClick: () => void createQueue(), disabled: busy || submitting, "data-testid": "codex-create-queue-button" }, "创建 queue"),
),
),
+70
View File
@@ -0,0 +1,70 @@
import React, { type ReactNode } from "react";
const h = React.createElement;
function joinClassNames(...values: Array<string | undefined | null | false>): string {
return values.filter(Boolean).join(" ");
}
export interface UniDeskDialogProps {
title: ReactNode;
children?: ReactNode;
actions?: ReactNode;
onClose: () => void;
className?: string;
backdropClassName?: string;
bodyClassName?: string;
actionsClassName?: string;
titleId?: string;
testId?: string;
closeTestId?: string;
closeLabel?: string;
disableClose?: boolean;
closeOnBackdrop?: boolean;
}
export function UniDeskDialog({
title,
children,
actions,
onClose,
className,
backdropClassName,
bodyClassName,
actionsClassName,
titleId,
testId,
closeTestId,
closeLabel = "关闭",
disableClose = false,
closeOnBackdrop = true,
}: UniDeskDialogProps) {
const sectionProps: Record<string, unknown> = {
className: joinClassNames("raw-dialog", "unidesk-dialog", className),
role: "dialog",
"aria-modal": "true",
};
if (titleId) {
sectionProps["aria-labelledby"] = titleId;
} else if (typeof title === "string") {
sectionProps["aria-label"] = title;
}
if (testId) sectionProps["data-testid"] = testId;
return h("div", {
className: joinClassNames("modal-backdrop", "unidesk-dialog-backdrop", backdropClassName),
role: "presentation",
onClick: (event: React.MouseEvent<HTMLElement>) => {
if (closeOnBackdrop && !disableClose && event.target === event.currentTarget) onClose();
},
},
h("section", sectionProps,
h("div", { className: "raw-dialog-head unidesk-dialog-head" },
h("strong", titleId ? { id: titleId } : null, title),
h("button", { type: "button", className: "ghost-btn", onClick: onClose, disabled: disableClose, "data-testid": closeTestId }, closeLabel),
),
h("div", { className: joinClassNames("unidesk-dialog-body", bodyClassName) }, children),
actions ? h("div", { className: joinClassNames("unidesk-dialog-actions", actionsClassName) }, actions) : null,
),
);
}
+10 -11
View File
@@ -707,10 +707,9 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
}
const overviewCacheKey = `${suffix}${url.search}`;
const canUseOverviewCache = req.method === "GET" && suffix === "/api/tasks/overview";
const isEventStreamRequest = req.method === "GET" && suffix === "/api/events";
const cacheControl = req.headers.get("cache-control") || "";
const bypassOverviewCache = /\bno-cache\b|\bno-store\b/iu.test(cacheControl) || req.headers.get("x-unidesk-no-cache") === "1";
const expectsJsonResponse = !isEventStreamRequest && (suffix === "/health" || suffix.startsWith("/api/"));
const expectsJsonResponse = suffix === "/health" || suffix.startsWith("/api/");
if (req.method !== "GET" && req.method !== "HEAD") invalidateCodeQueueOverviewCache();
if (canUseOverviewCache && !bypassOverviewCache) {
const cached = cachedCodeQueueOverview(overviewCacheKey);
@@ -725,7 +724,7 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
headers.delete("connection");
headers.delete("content-length");
headers.delete("cookie");
const init: RequestInit = { method: req.method, headers, redirect: "manual" };
const init: RequestInit = { method: req.method, headers, redirect: "manual", signal: req.signal };
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.arrayBuffer();
}
@@ -736,13 +735,6 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise<Response> {
const responseHeaders = new Headers();
const upstreamContentType = upstream.headers.get("content-type");
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
if (isEventStreamRequest) {
responseHeaders.set("cache-control", upstream.headers.get("cache-control") || "no-store, no-transform");
responseHeaders.set("connection", "keep-alive");
const buffering = upstream.headers.get("x-accel-buffering");
if (buffering !== null) responseHeaders.set("x-accel-buffering", buffering);
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
const upstreamBody = await upstream.arrayBuffer();
const isJsonResponse = (upstreamContentType ?? "").toLowerCase().includes("json");
let parsedJson: unknown = null;
@@ -821,7 +813,7 @@ async function proxyApi(req: Request, url: URL): Promise<Response> {
headers.delete("connection");
headers.delete("content-length");
headers.delete("cookie");
const init: RequestInit = { method: req.method, headers, redirect: "manual" };
const init: RequestInit = { method: req.method, headers, redirect: "manual", signal: req.signal };
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.arrayBuffer();
}
@@ -839,6 +831,13 @@ async function proxyApi(req: Request, url: URL): Promise<Response> {
const responseHeaders = new Headers();
const upstreamContentType = upstream.headers.get("content-type");
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
if ((upstreamContentType ?? "").toLowerCase().includes("text/event-stream")) {
responseHeaders.set("cache-control", upstream.headers.get("cache-control") || "no-store, no-transform");
responseHeaders.set("connection", "keep-alive");
const buffering = upstream.headers.get("x-accel-buffering");
if (buffering !== null) responseHeaders.set("x-accel-buffering", buffering);
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: responseHeaders });
}
@@ -69,6 +69,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "claudeqq", label: "ClaudeQQ" },
{ id: "baidu-netdisk", label: "Baidu Netdisk" },
{ id: "filebrowser", label: "File Browser" },
{ id: "oa-event-flow", label: "OA Event Flow" },
{ id: "code-queue", label: "Code Queue" },
{ id: "project-manager", label: "Project Manager" },
] },
@@ -0,0 +1,332 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect, useMemo } = React;
const useState: any = React.useState;
function StatusBadge({ status, children, title }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}`, title }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading }),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen?.(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function objectRecord(value: any): AnyRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function fmtCount(value: any): string {
const number = Number(value);
return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "--";
}
function compactText(value: any, max = 140): string {
if (value === null || value === undefined) return "--";
const text = typeof value === "string" ? value : JSON.stringify(value);
const normalized = String(text || "").replace(/\s+/gu, " ").trim();
return normalized.length > max ? `${normalized.slice(0, max - 1)}...` : normalized || "--";
}
function eventTags(event: any): string[] {
return asArray(event?.tags).map((tag) => String(tag || "").trim()).filter(Boolean);
}
function statNumber(value: any): number {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0;
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function normalizeTagFilter(value: string): string {
return String(value || "")
.split(/[\s,]+/u)
.map((tag) => tag.trim())
.filter(Boolean)
.join(",");
}
function oaProxy(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/oa-event-flow/proxy${path}`;
}
function eventTypeTone(type: string): string {
if (type.includes("error") || type.includes("failed")) return "failed";
if (type.includes("stats")) return "ok";
if (type.includes("step") || type.includes("updated")) return "running";
return "queued";
}
function statScopeLabel(stat: any): string {
const kind = String(stat?.subjectKind || "trace");
const id = String(stat?.subjectId || stat?.scopeId || "");
return id ? `${kind}:${id}` : String(stat?.scopeId || "--");
}
function EventTagRail({ tags }: AnyRecord) {
const visible = eventTags({ tags }).slice(0, 6);
return h("div", { className: "oa-tag-rail" },
visible.length === 0 ? h("span", { className: "muted" }, "--") : visible.map((tag) => h("code", { key: tag }, tag)),
);
}
function EventTable({ events, onRaw }: AnyRecord) {
const rows = [...asArray(events)].reverse();
return rows.length === 0
? h(EmptyState, { title: "事件表暂无记录", text: "等待 Code Queue 或 Pipeline 按 tag 发布 OA 事件" })
: h("div", { className: "table-wrap oa-event-table-wrap" }, h("table", { className: "oa-event-table", "data-testid": "oa-event-flow-event-table" },
h("thead", null, h("tr", null,
h("th", null, "Seq"),
h("th", null, "Type"),
h("th", null, "Source"),
h("th", null, "Aggregate"),
h("th", null, "Tags"),
h("th", null, "Payload"),
h("th", null, "Created"),
h("th", null, "Raw"),
)),
h("tbody", null, rows.map((event: any) => {
const type = String(event?.type || "event");
const aggregate = `${String(event?.aggregateType || "--")}:${String(event?.aggregateId || "--")}`;
return h("tr", { key: event?.eventId || event?.sequence },
h("td", null, h("code", null, fmtCount(event?.sequence))),
h("td", null, h(StatusBadge, { status: eventTypeTone(type) }, type)),
h("td", null, h("strong", null, event?.sourceId || "--"), h("code", null, event?.sourceKind || "--")),
h("td", null, h("code", null, aggregate)),
h("td", null, h(EventTagRail, { tags: event?.tags })),
h("td", null, h("span", { className: "oa-payload-preview" }, compactText(event?.payload, 180))),
h("td", null, fmtDate(event?.createdAt)),
h("td", null, h(RawButton, { title: `OA Event ${event?.sequence || ""}`, data: event, onOpen: onRaw, testId: `raw-oa-event-${event?.sequence || "unknown"}` })),
);
})),
));
}
function StatsTable({ stats, onRaw }: AnyRecord) {
const rows = asArray(stats);
return rows.length === 0
? h(EmptyState, { title: "统计中心暂无投影", text: "trace-stats-snapshot / trace-step-created 进入事件流后会更新这里" })
: h("div", { className: "table-wrap oa-stats-table-wrap" }, h("table", { className: "oa-stats-table", "data-testid": "oa-event-flow-stats" },
h("thead", null, h("tr", null,
h("th", null, "Scope"),
h("th", null, "Service"),
h("th", null, "STEP"),
h("th", null, "Read"),
h("th", null, "Edit"),
h("th", null, "Run"),
h("th", null, "Error"),
h("th", null, "Output Seq"),
h("th", null, "Revision"),
h("th", null, "Updated"),
h("th", null, "Raw"),
)),
h("tbody", null, rows.map((stat: any) => h("tr", { key: stat?.scopeId || `${stat?.serviceId}-${stat?.subjectId}` },
h("td", null, h("strong", null, statScopeLabel(stat)), h("code", null, stat?.scopeId || "--")),
h("td", null, h(StatusBadge, { status: String(stat?.serviceId || "unknown") === "code-queue" ? "running" : "queued" }, stat?.serviceId || "--")),
h("td", null, h("strong", null, fmtCount(statNumber(stat?.stepCount ?? stat?.llmStepCount)))),
h("td", null, fmtCount(statNumber(stat?.readCount))),
h("td", null, fmtCount(statNumber(stat?.editCount))),
h("td", null, fmtCount(statNumber(stat?.runCount))),
h("td", null, fmtCount(statNumber(stat?.errorCount))),
h("td", null, h("code", null, fmtCount(statNumber(stat?.outputMaxSeq)))),
h("td", null, fmtCount(statNumber(stat?.statsRevision))),
h("td", null, fmtDate(stat?.updatedAt)),
h("td", null, h(RawButton, { title: `OA Trace Stats ${stat?.scopeId || ""}`, data: stat, onOpen: onRaw, testId: `raw-oa-stats-${String(stat?.scopeId || "unknown").replace(/[^a-zA-Z0-9_-]/gu, "_")}` })),
))),
));
}
export function OaEventFlowPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "oa-event-flow") || null;
const [tagFilter, setTagFilter] = useState("service:code-queue");
const [state, setState] = useState({ loading: false, error: "", health: null, diagnostics: null, events: [], stats: [], refreshedAt: null });
const [streamState, setStreamState] = useState({ status: "idle", message: "未连接", lastEventAt: "" });
const normalizedTags = useMemo(() => normalizeTagFilter(tagFilter), [tagFilter]);
async function load(): Promise<void> {
if (!service) return;
setState((previous: any) => ({ ...previous, loading: true, error: "" }));
try {
const tagQuery = normalizedTags ? `tags=${encodeURIComponent(normalizedTags)}&` : "";
const [health, diagnostics, events, stats] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/oa-event-flow/health`, { failureFields: [] }),
requestJson(oaProxy(apiBaseUrl, "/api/diagnostics")),
requestJson(oaProxy(apiBaseUrl, `/api/events?${tagQuery}limit=100`)),
requestJson(oaProxy(apiBaseUrl, `/api/stats/trace?${tagQuery}limit=100`)),
]);
setState({
loading: false,
error: "",
health,
diagnostics,
events: asArray(events?.events),
stats: asArray(stats?.stats),
refreshedAt: new Date(),
});
} catch (err) {
setState((previous: any) => ({ ...previous, loading: false, error: errorMessage(err, "OA Event Flow 加载失败") }));
}
}
useEffect(() => {
void load();
}, [service?.id, service?.runtime?.providerStatus, normalizedTags]);
useEffect(() => {
if (!service || typeof EventSource === "undefined") return undefined;
const tagQuery = normalizedTags ? `?tags=${encodeURIComponent(normalizedTags)}` : "";
const source = new EventSource(`${oaProxy(apiBaseUrl, "/api/events/stream")}${tagQuery}`, { withCredentials: true });
setStreamState({ status: "running", message: "SSE connecting", lastEventAt: "" });
const onHello = (message: MessageEvent): void => {
setStreamState({ status: "online", message: compactText(message.data, 120), lastEventAt: new Date().toISOString() });
};
const onMessage = (message: MessageEvent): void => {
try {
const event = JSON.parse(String(message.data || "{}"));
setStreamState({ status: "online", message: String(event?.type || message.type || "event"), lastEventAt: new Date().toISOString() });
setState((previous: any) => {
const events = [...asArray(previous.events).filter((row: any) => String(row?.eventId || "") !== String(event?.eventId || "")), event]
.sort((left: any, right: any) => Number(left?.sequence || 0) - Number(right?.sequence || 0))
.slice(-100);
const stats = event?.type === "trace-stats-updated" && objectRecord(event?.payload?.stats)
? [event.payload.stats, ...asArray(previous.stats).filter((row: any) => String(row?.scopeId || "") !== String(event.payload.stats.scopeId || ""))].slice(0, 100)
: previous.stats;
return { ...previous, events, stats };
});
} catch (err) {
setStreamState({ status: "warn", message: errorMessage(err, "SSE 事件解析失败"), lastEventAt: new Date().toISOString() });
}
};
const onError = (): void => {
setStreamState((previous: any) => ({ ...previous, status: "warn", message: "SSE reconnecting" }));
};
source.addEventListener("hello", onHello);
source.addEventListener("task-updated", onMessage);
source.addEventListener("queue-updated", onMessage);
source.addEventListener("trace-step-created", onMessage);
source.addEventListener("trace-stats-snapshot", onMessage);
source.addEventListener("trace-stats-updated", onMessage);
source.addEventListener("trace-error", onMessage);
source.onerror = onError;
return () => source.close();
}, [service?.id, apiBaseUrl, normalizedTags]);
if (!service) return h(EmptyState, { title: "OA Event Flow 未登记", text: "请在 config.json 的 microservices 中登记 id=oa-event-flow" });
const runtime = microserviceRuntime(service);
const backend = microserviceBackend(service);
const diagnostics = state.diagnostics || {};
const health = state.health || {};
const eventCount = diagnostics.eventCount ?? health.eventCount;
const statsCount = diagnostics.traceStatsCount ?? health.traceStatsCount;
const latestSequence = diagnostics.latestSequence ?? health.latestSequence;
const pipelineBridge = diagnostics.pipelineBridge || health.pipelineBridge || {};
const eventTypes = asArray(diagnostics.eventTypes).slice(0, 8);
return h("div", { className: "oa-event-flow-page", "data-testid": "oa-event-flow-page" },
h(Panel, {
title: "OA Event Flow 控制台",
eyebrow: "Unified OA Event Bus + Stats Projection",
loading: state.loading,
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "oa-event-flow-refresh" }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "OA Event Flow Service", data: service, onOpen: onRaw, testId: "raw-oa-event-flow-service" }),
),
},
h("div", { className: "oa-flow-hero" },
h("div", null,
h("div", { className: "node-version-line" },
h(StatusBadge, { status: health?.ok || runtime.providerStatus === "online" ? "online" : "warn" }, health?.ok ? "HEALTH OK" : runtime.providerStatus || "unknown"),
h(StatusBadge, { status: streamState.status }, streamState.status.toUpperCase()),
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
),
h("p", { className: "muted paragraph" }, "独立事件流微服务统一承载 Code Queue 与 Pipeline 的事件发布、tag 订阅、事件表审计和 Trace/STEP 统计投影。"),
),
h("div", { className: "oa-flow-signal" },
h("span", null, "stream"),
h("strong", null, streamState.message || "--"),
h("code", null, streamState.lastEventAt ? fmtClock(new Date(streamState.lastEventAt)) : "waiting"),
),
),
h(UniDeskErrorBanner, { error: state.error, wide: true }),
),
h("div", { className: "oa-flow-metrics" },
h(MetricCard, { label: "事件总量", value: fmtCount(eventCount), hint: `latest seq ${fmtCount(latestSequence)}`, tone: "ok" }),
h(MetricCard, { label: "Trace Stats", value: fmtCount(statsCount), hint: "oa_trace_stats 投影" }),
h(MetricCard, { label: "SSE Clients", value: fmtCount(health?.sseClientCount ?? asArray(diagnostics.sseClients).length), hint: streamState.message || "tag subscription" }),
h(MetricCard, { label: "Pipeline Bridge", value: pipelineBridge?.enabled ? fmtCount(pipelineBridge?.insertedCount) : "OFF", hint: pipelineBridge?.lastError || pipelineBridge?.lastFinishedAt || `${pipelineBridge?.mode || "snapshot"} service:pipeline` }),
h(MetricCard, { label: "DB", value: health?.databaseReady || diagnostics.databaseReady ? "READY" : "WAIT", hint: health?.databaseLastError || diagnostics.databaseLastError || "PostgreSQL persisted" }),
),
h(Panel, { title: "标签订阅", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Tag Pub/Sub" },
h("div", { className: "oa-filter-bar" },
h("label", null, h("span", null, "tags"), h("input", { value: tagFilter, onChange: (event: any) => setTagFilter(event.target.value), placeholder: "service:code-queue, trace", "data-testid": "oa-event-flow-tag-filter" })),
h("div", { className: "oa-filter-presets" },
h("button", { type: "button", className: "ghost-btn", onClick: () => setTagFilter("service:code-queue") }, "Code Queue"),
h("button", { type: "button", className: "ghost-btn", onClick: () => setTagFilter("service:pipeline") }, "Pipeline"),
h("button", { type: "button", className: "ghost-btn", onClick: () => setTagFilter("trace") }, "Trace"),
h("button", { type: "button", className: "ghost-btn", onClick: () => setTagFilter("") }, "All"),
),
h("code", null, normalizedTags || "all events"),
),
h("div", { className: "oa-type-strip" },
eventTypes.length === 0 ? h("span", { className: "muted" }, "等待事件类型统计") : eventTypes.map((row: any) => h("span", { key: row.type, className: "data-chip" }, `${row.type} ${fmtCount(row.count)}`)),
),
),
h("div", { className: "oa-flow-grid" },
h(Panel, { title: "事件表", eyebrow: "oa_events persisted log", className: "oa-flow-wide", loading: state.loading,
actions: h(RawButton, { title: "OA Event Query", data: { events: state.events, diagnostics }, onOpen: onRaw, testId: "raw-oa-events" }),
}, h(EventTable, { events: state.events, onRaw })),
h(Panel, { title: "统计中心", eyebrow: "oa_trace_stats read model", className: "oa-flow-wide", loading: state.loading,
actions: h(RawButton, { title: "OA Trace Stats", data: state.stats, onOpen: onRaw, testId: "raw-oa-trace-stats" }),
}, h(StatsTable, { stats: state.stats, onRaw })),
),
);
}
+23 -71
View File
@@ -847,7 +847,7 @@ function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedA
),
h(PipelineKvGrid, { items: [
{ label: "messages", value: messages.messageCount ?? "--" },
{ label: "steps", value: messages.stepCount ?? steps.length },
{ label: "steps", value: messages.stepCount ?? "--" },
{ label: "tools", value: messages.toolCallCount ?? "--" },
{ label: "updated", value: fmtDate(messages.updatedAt) },
{ label: "sessions", value: sessionIds.join(", ") || "--" },
@@ -886,8 +886,7 @@ function pipelineGanttDetailPayload(baseDetails: any, nodeDetails: any, runId: s
return {
...base,
...nodeDetails,
// Keep run-level evidence unless the node endpoint has a more focused copy.
controlCommands: asArray(nodeDetails.controlCommands).length > 0 ? nodeDetails.controlCommands : base.controlCommands,
// Keep normalized OA control events unless the node endpoint has a focused copy.
controlEvents: asArray(nodeDetails.controlEvents).length > 0 ? nodeDetails.controlEvents : base.controlEvents,
procedureRuns: nodeProcedures.length > 0 ? nodeProcedures : base.procedureRuns,
};
@@ -2342,12 +2341,12 @@ function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyReco
return { markers, arrows: nextArrows };
}
function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string {
const kind = eventKind(record) || fallbackKind;
function pipelinePromptMarkerTone(record: any): string {
const kind = eventKind(record);
const promptEvent = String(record?.promptEvent || "");
if (kind === "initial-prompt-delivered") return "initial";
if (promptEvent === "node-finished" || promptEvent === "node-long-running-observation" || promptEvent.startsWith("monitor-")) return "monitor";
if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor";
if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor") return "monitor";
return "append";
}
@@ -2355,16 +2354,16 @@ function pipelineRecordTags(record: any): string[] {
return asArray(record?.tags || record?.raw?.tags).map((tag) => String(tag || "")).filter(Boolean);
}
function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string {
const kind = eventKind(record) || fallbackKind;
function pipelinePromptMarkerLabel(record: any): string {
const kind = eventKind(record);
const promptEvent = String(record?.promptEvent || "");
if (kind === "initial-prompt-delivered") return "初始 prompt";
if (promptEvent === "node-long-running-observation") return "长任务观察";
if (promptEvent === "node-finished") return pipelineRecordTags(record).includes("monitor.audit") ? "节点完成 / OA 审核" : "节点完成";
if (promptEvent === "monitor-interval") return "旧版轮询";
if (promptEvent === "monitor-interval") return "Monitor observation";
if (promptEvent === "monitor-start") return "Monitor start";
if (promptEvent === "monitor-stop") return "Monitor stop";
if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt";
if (kind === "monitor-prompt-delivered") return "Monitor prompt";
if (kind === "append-prompt-queued") return "追加 prompt 已排队";
return "追加 prompt";
}
@@ -2382,7 +2381,7 @@ function controlRecordGroupKey(record: any, index: number): string {
if (commandId) return `command:${commandId}`;
const timestamp = eventTimestampIso(record) || firstIso(record?.createdAt, record?.timestamp) || `index-${index}`;
return [
"fallback",
"control-event",
timestamp,
String(record?.sourceKind || ""),
String(record?.sourceNodeId || ""),
@@ -2481,8 +2480,6 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
if (!nodeId) continue;
for (const attempt of asArray(procedure?.attempts)) {
const attemptId = attemptLabel(attempt);
const deliveredIds = new Set<string>();
const deliveredFallbackKeys = new Set<string>();
for (const record of structuredEventRecords(attempt?.controlEventRecords)) {
const kind = eventKind(record);
if (!["initial-prompt-delivered", "append-prompt-delivered", "monitor-prompt-delivered"].includes(kind)) continue;
@@ -2490,8 +2487,6 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
const ms = timeMs(timestampIso);
if (ms === null) continue;
const eventId = String(record?.eventId || "");
if (eventId) deliveredIds.add(eventId);
deliveredFallbackKeys.add(`${kind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`);
addMarker({
id: `prompt:${eventId || `${procedureRunId}:${attemptId}:${kind}:${ms}`}`,
runId,
@@ -2499,55 +2494,19 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
procedureRunId,
attempt: attemptId,
kind: "prompt",
tone: pipelinePromptMarkerTone(record, kind),
tone: pipelinePromptMarkerTone(record),
status: "delivered",
label: pipelinePromptMarkerLabel(record, kind),
label: pipelinePromptMarkerLabel(record),
ms,
timestampIso,
sourceKind: String(record?.sourceKind || ""),
sourceNodeId: String(record?.sourceNodeId || ""),
targetNodeId: nodeId,
action: "",
eventId,
commandId: String(record?.commandId || ""),
raw: record,
}, promptMarkers);
}
const fallbackSources = [
{ records: structuredEventRecords(attempt?.controlPromptRecords), fallbackKind: "append-prompt-queued" },
{ records: structuredEventRecords(attempt?.monitorPromptRecords), fallbackKind: "monitor-prompt-queued" },
];
for (const source of fallbackSources) {
for (const record of source.records) {
const timestampIso = eventTimestampIso(record);
const ms = timeMs(timestampIso);
if (ms === null) continue;
const eventId = String(record?.eventId || "");
if (eventId && deliveredIds.has(eventId)) continue;
const deliveredKind = source.fallbackKind === "monitor-prompt-queued" ? "monitor-prompt-delivered" : "append-prompt-delivered";
const fallbackKey = `${deliveredKind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`;
if (deliveredFallbackKeys.has(fallbackKey)) continue;
addMarker({
id: `prompt-fallback:${eventId || `${procedureRunId}:${attemptId}:${source.fallbackKind}:${ms}`}`,
runId,
nodeId,
procedureRunId,
attempt: attemptId,
kind: "prompt",
tone: pipelinePromptMarkerTone(record, source.fallbackKind),
status: "queued",
label: pipelinePromptMarkerLabel(record, source.fallbackKind),
ms,
timestampIso,
sourceKind: String(record?.sourceKind || ""),
sourceNodeId: String(record?.sourceNodeId || ""),
targetNodeId: nodeId,
action: "",
eventId,
commandId: String(record?.commandId || ""),
raw: record,
}, promptMarkers);
}
sourceKind: String(record?.sourceKind || ""),
sourceNodeId: String(record?.sourceNodeId || ""),
targetNodeId: nodeId,
action: "",
eventId,
commandId: String(record?.commandId || ""),
raw: record,
}, promptMarkers);
}
}
}
@@ -2555,22 +2514,15 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
const grouped = new Map<string, AnyRecord>();
structuredEventRecords(details?.controlEvents).forEach((record: AnyRecord, index: number) => {
const key = controlRecordGroupKey(record, index);
const group = grouped.get(key) || { key, events: [], commands: [] };
const group = grouped.get(key) || { key, events: [] };
group.events.push(record);
grouped.set(key, group);
});
asArray(details?.controlCommands).filter(isRecord).forEach((record: AnyRecord, index: number) => {
const key = controlRecordGroupKey(record, index);
const group = grouped.get(key) || { key, events: [], commands: [] };
group.commands.push(record);
grouped.set(key, group);
});
for (const group of grouped.values()) {
const events = asArray(group.events).slice().sort((left: any, right: any) => controlEventPriority(right) - controlEventPriority(left));
const commands = asArray(group.commands);
const queuedRecord = asArray(group.events).find((record: any) => eventKind(record) === "control-command-queued") || commands[0] || null;
const outcomeRecord = events[0] || commands[0] || queuedRecord;
const queuedRecord = asArray(group.events).find((record: any) => eventKind(record) === "control-command-queued") || null;
const outcomeRecord = events[0] || queuedRecord;
if (!queuedRecord && !outcomeRecord) continue;
const sourceNodeId = String(queuedRecord?.sourceNodeId || outcomeRecord?.sourceNodeId || "");
const sourceKind = String(queuedRecord?.sourceKind || outcomeRecord?.sourceKind || "");