fix: recover run runtime state and logs

This commit is contained in:
npc0-hue
2026-08-07 12:01:26 +08:00
parent 3818fa0344
commit b789925ae5
23 changed files with 297 additions and 78 deletions
+1
View File
@@ -462,6 +462,7 @@ export interface ServerInstanceResponse {
ownerUserId?: string;
adminUserIds: string[];
state: ServerInstanceState;
lifecycleObservedAt?: string;
configVersion: number;
configKey?: string;
configChecksum?: string;
@@ -314,7 +314,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
function handleTerminalScroll() {
const output = outputRef.current;
if (!output) return;
if (!output || initialHistoryPendingRef.current) return;
const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24;
followLatestRef.current = nextFollowLatest;
setFollowLatest(nextFollowLatest);
@@ -369,7 +369,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
<div className="terminal-output-topbar">
<div>
<strong>{serverName}</strong>
<span> + SSE · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
<span> + SSE · Run · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
</div>
<div>
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span></span></button>
@@ -378,7 +378,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
</div>
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text"> Run </span></div>}
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">Run </span></div>}
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><span className="terminal-text">{line.text}</span></div>)}
</div>
</section>
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { canStartServer, canStopServer } from "./serverManagement";
import type { ServerInstanceState } from "../api/types";
import { canStartServer, canStopServer, runtimeObservationFreshness } from "./serverManagement";
import type { RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types";
describe("server management lifecycle contracts", () => {
it("allows explicit starts from recoverable non-running states", () => {
@@ -16,4 +16,12 @@ describe("server management lifecycle contracts", () => {
expect(canStopServer("running")).toBe(true);
expect(canStopServer("failed")).toBe(false);
});
it("distinguishes a fresh Run observation from an unverified historical lifecycle state", () => {
const instance = { id: "server-1", state: "running", runEndpointId: "run-1" } as ServerInstanceResponse;
const endpoint = { id: "run-1", status: "online", lastHeartbeatAt: "2026-08-07T10:00:00Z" } as RunEndpointResponse;
expect(runtimeObservationFreshness(instance, endpoint, Date.parse("2026-08-07T10:00:30Z"))).toBe("fresh");
expect(runtimeObservationFreshness(instance, endpoint, Date.parse("2026-08-07T10:01:00Z"))).toBe("unverified");
expect(runtimeObservationFreshness(instance, { ...endpoint, status: "offline" }, Date.parse("2026-08-07T10:00:01Z"))).toBe("unverified");
});
});
@@ -61,6 +61,14 @@ export interface ServerManagementSummary {
failed: number;
}
export type RuntimeObservationFreshness = "fresh" | "unverified";
export function runtimeObservationFreshness(instance: ServerInstanceResponse, endpoint: RunEndpointResponse | undefined, now = Date.now()): RuntimeObservationFreshness {
if (!endpoint || endpoint.status !== "online") return "unverified";
const heartbeat = Date.parse(endpoint.lastHeartbeatAt);
return Number.isFinite(heartbeat) && now-heartbeat <= 45_000 ? "fresh" : "unverified";
}
export const emptyServerCreateForm: ServerCreateFormState = {
id: "",
name: "",
+4 -3
View File
@@ -1,4 +1,4 @@
import type { JobResponse, ServerInstanceResponse, ServerMetricsResponse, UserContactProfile, UserStatus, UserThemePreferenceResponse } from "../api/types";
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse, UserContactProfile, UserStatus, UserThemePreferenceResponse } from "../api/types";
export type WorkspaceRole = "platformAdmin" | "serverOwner" | "serverAdmin";
@@ -89,8 +89,9 @@ export interface OperationRecord {
}
export interface ServerCardView {
instance: ServerInstanceResponse;
metrics?: ServerMetricsResponse;
instance: ServerInstanceResponse;
endpoint?: RunEndpointResponse;
metrics?: ServerMetricsResponse;
pendingJobs: number;
activeJobs?: number;
failedJobs?: number;
+12 -9
View File
@@ -23,8 +23,9 @@ import type { PageComponentProps } from "../contracts/page";
import {
canDeleteServer,
defaultServerCreateForm,
endpointLabel,
pluginCreateInputDefaults,
endpointLabel,
pluginCreateInputDefaults,
runtimeObservationFreshness,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { summarizeServerOperations } from "../contracts/operationsConsole";
@@ -152,15 +153,16 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const cards = useMemo<ServerCardView[]>(
() =>
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
instance: summary.instance,
metrics: summary.metrics,
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
instance: summary.instance,
endpoint: endpoints.find((endpoint) => endpoint.id === summary.instance.runEndpointId),
metrics: summary.metrics,
pendingJobs: summary.activeJobs,
activeJobs: summary.activeJobs,
failedJobs: summary.failedJobs,
latestJob: summary.latestJob
})),
[instances, jobs, metrics]
[endpoints, instances, jobs, metrics]
);
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
@@ -725,8 +727,9 @@ interface ServerCardProps {
}
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const { instance, endpoint, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const freshness = runtimeObservationFreshness(instance, endpoint);
const canDelete = deleteDisabledReason === "";
const canOpenActions = canManage || canDelete;
const metricsWaiting = metrics?.source === "run-metrics-pending";
@@ -815,7 +818,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<strong>{instance.name}</strong>
<span className="provider-id">{instance.id}</span>
</span>
<span className={cx("status-pill", statusClass(instance.state))}>{stateLabel(instance.state)}</span>
<span className={cx("status-pill", freshness === "fresh" ? statusClass(instance.state) : "status-disabled")}>{freshness === "fresh" ? stateLabel(instance.state) : `最后观测:${stateLabel(instance.state)}Run 未验证)`}</span>
</div>
<div className="server-card-stats">
<span className="server-card-stat">