fix: recover run runtime state and logs
This commit is contained in:
@@ -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: "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user