179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
|
|
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
|
import type { OperationRecord } from "./workspace";
|
|
|
|
export type OperationsModuleState<T> =
|
|
| { status: "loading"; refreshedAt?: string }
|
|
| { status: "error"; reason: string; diagnosticId: string; refreshedAt?: string }
|
|
| { status: "ready"; data: T; refreshedAt: string };
|
|
|
|
export interface JobBuckets {
|
|
active: JobResponse[];
|
|
retrying: JobResponse[];
|
|
failed: JobResponse[];
|
|
succeeded: JobResponse[];
|
|
cancelled: JobResponse[];
|
|
}
|
|
|
|
export interface ServerOperationsSummary {
|
|
instance: ServerInstanceResponse;
|
|
metrics?: ServerMetricsResponse;
|
|
activeJobs: number;
|
|
failedJobs: number;
|
|
latestJob?: JobResponse;
|
|
}
|
|
|
|
export interface EndpointOperationsSummary {
|
|
total: number;
|
|
online: number;
|
|
degraded: number;
|
|
offline: number;
|
|
disabled: number;
|
|
activeJobs: number;
|
|
queuedJobs: number;
|
|
}
|
|
|
|
export interface OperationTrayItem {
|
|
id: string;
|
|
intent: string;
|
|
targetLabel: string;
|
|
status: OperationRecord["status"];
|
|
message?: string;
|
|
errorReason?: string;
|
|
diagnosticId?: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
const activeJobStates = new Set<JobResponse["state"]>(["queued", "accepted", "running", "retrying"]);
|
|
|
|
export function isActiveJob(job: JobResponse): boolean {
|
|
return activeJobStates.has(job.state);
|
|
}
|
|
|
|
export function jobBuckets(jobs: JobResponse[]): JobBuckets {
|
|
const sorted = [...jobs].sort((left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt));
|
|
return {
|
|
active: sorted.filter(isActiveJob),
|
|
retrying: sorted.filter((job) => job.state === "retrying"),
|
|
failed: sorted.filter((job) => job.state === "failed"),
|
|
succeeded: sorted.filter((job) => job.state === "succeeded"),
|
|
cancelled: sorted.filter((job) => job.state === "cancelled")
|
|
};
|
|
}
|
|
|
|
export function summarizeServerOperations(
|
|
instances: ServerInstanceResponse[],
|
|
metrics: Map<string, ServerMetricsResponse>,
|
|
jobs: JobResponse[]
|
|
): ServerOperationsSummary[] {
|
|
const jobsByServer = new Map<string, JobResponse[]>();
|
|
for (const job of jobs) {
|
|
if (!job.serverInstanceId) {
|
|
continue;
|
|
}
|
|
const serverJobs = jobsByServer.get(job.serverInstanceId) ?? [];
|
|
serverJobs.push(job);
|
|
jobsByServer.set(job.serverInstanceId, serverJobs);
|
|
}
|
|
|
|
return instances
|
|
.map((instance) => {
|
|
const serverJobs = [...(jobsByServer.get(instance.id) ?? [])].sort(
|
|
(left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt)
|
|
);
|
|
return {
|
|
instance,
|
|
metrics: metrics.get(instance.id),
|
|
activeJobs: serverJobs.filter(isActiveJob).length,
|
|
failedJobs: serverJobs.filter((job) => job.state === "failed").length,
|
|
latestJob: serverJobs[0]
|
|
};
|
|
})
|
|
.sort(compareServerOperations);
|
|
}
|
|
|
|
export function summarizeEndpointOperations(endpoints: RunEndpointResponse[]): EndpointOperationsSummary {
|
|
return endpoints.reduce<EndpointOperationsSummary>(
|
|
(summary, endpoint) => ({
|
|
total: summary.total + 1,
|
|
online: summary.online + Number(endpoint.status === "online"),
|
|
degraded: summary.degraded + Number(endpoint.status === "degraded"),
|
|
offline: summary.offline + Number(endpoint.status === "offline"),
|
|
disabled: summary.disabled + Number(endpoint.status === "disabled"),
|
|
activeJobs: summary.activeJobs + endpoint.capacity.runningJobs,
|
|
queuedJobs: summary.queuedJobs + endpoint.capacity.queuedJobs
|
|
}),
|
|
{ total: 0, online: 0, degraded: 0, offline: 0, disabled: 0, activeJobs: 0, queuedJobs: 0 }
|
|
);
|
|
}
|
|
|
|
export function projectOperationForTray(operation: OperationRecord): OperationTrayItem {
|
|
return {
|
|
id: operation.id,
|
|
intent: operation.intent,
|
|
targetLabel: safeOperationTarget(operation.targetKind, operation.targetId),
|
|
status: operation.status,
|
|
message: safeDiagnosticText(operation.message),
|
|
errorReason: safeDiagnosticText(operation.errorReason),
|
|
diagnosticId: safeDiagnosticId(operation.diagnosticId),
|
|
updatedAt: operation.updatedAt
|
|
};
|
|
}
|
|
|
|
export function moduleFreshnessLabel(refreshedAt?: string): string {
|
|
if (!refreshedAt) {
|
|
return "尚未刷新";
|
|
}
|
|
const time = new Date(refreshedAt);
|
|
return Number.isNaN(time.getTime()) ? "刷新时间未知" : `刷新于 ${time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
|
}
|
|
|
|
function compareServerOperations(left: ServerOperationsSummary, right: ServerOperationsSummary): number {
|
|
const attentionDifference = serverAttentionRank(right) - serverAttentionRank(left);
|
|
if (attentionDifference !== 0) {
|
|
return attentionDifference;
|
|
}
|
|
const updatedDifference = timestamp(right.instance.updatedAt) - timestamp(left.instance.updatedAt);
|
|
return updatedDifference !== 0 ? updatedDifference : left.instance.name.localeCompare(right.instance.name, "zh-CN");
|
|
}
|
|
|
|
function serverAttentionRank(summary: ServerOperationsSummary): number {
|
|
if (summary.instance.state === "failed") {
|
|
return 50;
|
|
}
|
|
if (summary.failedJobs > 0) {
|
|
return 40;
|
|
}
|
|
if (summary.instance.state === "installing") {
|
|
return 30;
|
|
}
|
|
if (summary.activeJobs > 0) {
|
|
return 20;
|
|
}
|
|
if (summary.instance.state === "running") {
|
|
return 10;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function safeOperationTarget(kind: OperationRecord["targetKind"], targetId: string): string {
|
|
const kindLabel: Record<OperationRecord["targetKind"], string> = {
|
|
server: "服务器",
|
|
plugin: "插件",
|
|
config: "配置",
|
|
llm: "AI 提供商",
|
|
platform: "平台"
|
|
};
|
|
const safeId = /^[a-zA-Z0-9._:-]{1,96}$/.test(targetId) ? targetId : "受保护目标";
|
|
return `${kindLabel[kind]} / ${safeId}`;
|
|
}
|
|
|
|
function safeDiagnosticId(value: string | undefined): string | undefined {
|
|
return value && /^[a-zA-Z0-9._:-]{1,96}$/.test(value) ? value : value ? "受保护诊断" : undefined;
|
|
}
|
|
|
|
function timestamp(value: string): number {
|
|
const parsed = Date.parse(value);
|
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
}
|