Improve microservice health availability
This commit is contained in:
@@ -185,6 +185,18 @@ interface MicroserviceProxyCacheEntry {
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
interface MicroserviceHealthAssessment {
|
||||
healthy: boolean;
|
||||
reason: string;
|
||||
checks: Record<string, JsonValue>;
|
||||
body: JsonValue;
|
||||
}
|
||||
|
||||
interface MicroserviceAvailabilityEntry {
|
||||
expiresAt: number;
|
||||
probe: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
const recentLogs: unknown[] = [];
|
||||
const activeProviders = new Map<string, ProviderSocket>();
|
||||
const activeSshClients = new Map<string, ProviderSocket>();
|
||||
@@ -204,10 +216,13 @@ const maxPerformanceSamples = 3000;
|
||||
const taskTerminalWaiters = new Map<string, Set<TaskTerminalWaiter>>();
|
||||
const microserviceProxyCache = new Map<string, MicroserviceProxyCacheEntry>();
|
||||
const microserviceProxyRefreshes = new Map<string, Promise<void>>();
|
||||
const microserviceAvailabilityCache = new Map<string, MicroserviceAvailabilityEntry>();
|
||||
const microserviceAvailabilityRefreshes = new Map<string, Promise<Record<string, JsonValue>>>();
|
||||
const activeScheduledRuns = new Set<string>();
|
||||
let lastTaskSweepAt = 0;
|
||||
let taskSweepInFlight: Promise<void> | null = null;
|
||||
const microserviceProxyMaxBodyTextLength = 8 * 1024 * 1024;
|
||||
const microserviceAvailabilityTtlMs = 30_000;
|
||||
const microserviceForwardRequestHeaders = [
|
||||
"accept",
|
||||
"content-type",
|
||||
@@ -1445,7 +1460,7 @@ async function getPgdataUsage(): Promise<JsonValue> {
|
||||
}
|
||||
|
||||
async function getOverview(): Promise<JsonValue> {
|
||||
const [nodeRows, dockerRows, systemRows, pendingTasks, pgdata] = await Promise.all([
|
||||
const [nodeRows, dockerRows, systemRows, pendingTasks, pgdata, microserviceAvailabilitySummary] = await Promise.all([
|
||||
sql<Array<{ node_count: string | number; online_node_count: string | number }>>`
|
||||
SELECT
|
||||
count(*)::int AS node_count,
|
||||
@@ -1464,6 +1479,7 @@ async function getOverview(): Promise<JsonValue> {
|
||||
`,
|
||||
countPendingTasks(),
|
||||
getPgdataUsage(),
|
||||
getMicroserviceAvailabilitySummary(),
|
||||
]);
|
||||
const nodeRow = nodeRows[0];
|
||||
const dockerRow = dockerRows[0];
|
||||
@@ -1479,6 +1495,7 @@ async function getOverview(): Promise<JsonValue> {
|
||||
dockerStatusNodeCount: Number(dockerRow?.docker_status_node_count ?? 0),
|
||||
systemStatusNodeCount: Number(systemRow?.system_status_node_count ?? 0),
|
||||
pendingTaskCount: pendingTasks,
|
||||
microserviceAvailability: microserviceAvailabilitySummary,
|
||||
taskPendingTimeoutMs: config.taskPendingTimeoutMs,
|
||||
activeSocketCount: activeProviders.size,
|
||||
heartbeatTimeoutMs: config.heartbeatTimeoutMs,
|
||||
@@ -1503,6 +1520,38 @@ function findContainer(status: JsonValue | null, containerName: string): JsonVal
|
||||
return container === undefined ? null : container as JsonValue;
|
||||
}
|
||||
|
||||
function inferredMicroserviceAvailability(service: MicroserviceConfig, providerStatus: string, container: JsonValue | null): JsonValue {
|
||||
const cached = cachedMicroserviceAvailability(service.id);
|
||||
if (cached !== null) return cached;
|
||||
const containerState = isPlainRecord(container) ? String(container.state ?? "").toLowerCase() : "";
|
||||
const containerStatus = isPlainRecord(container) ? String(container.status ?? "") : "";
|
||||
if (providerStatus !== "online") {
|
||||
return {
|
||||
serviceId: service.id,
|
||||
healthy: false,
|
||||
status: "unhealthy",
|
||||
reason: `provider is ${providerStatus}`,
|
||||
source: "runtime-inference",
|
||||
};
|
||||
}
|
||||
if (container !== null && containerState !== "running") {
|
||||
return {
|
||||
serviceId: service.id,
|
||||
healthy: false,
|
||||
status: "unhealthy",
|
||||
reason: `container is ${containerStatus || containerState || "not running"}`,
|
||||
source: "runtime-inference",
|
||||
};
|
||||
}
|
||||
return {
|
||||
serviceId: service.id,
|
||||
healthy: null,
|
||||
status: "unknown",
|
||||
reason: "strict health has not been probed yet",
|
||||
source: "runtime-inference",
|
||||
};
|
||||
}
|
||||
|
||||
async function getMicroservices(): Promise<JsonValue[]> {
|
||||
const nodes = await getNodes();
|
||||
const dockerStatuses = await getNodeDockerStatuses();
|
||||
@@ -1517,6 +1566,7 @@ async function getMicroservices(): Promise<JsonValue[]> {
|
||||
providerName: node?.name ?? service.providerId,
|
||||
providerLastHeartbeat: node?.lastHeartbeat ?? null,
|
||||
container,
|
||||
availability: inferredMicroserviceAvailability(service, node?.status ?? "missing", container),
|
||||
backendPortMapping: {
|
||||
providerId: service.providerId,
|
||||
node: `${service.backend.nodeBindHost}:${service.backend.nodePort}`,
|
||||
@@ -2464,7 +2514,267 @@ function responseFromMicroserviceResult(task: Awaited<ReturnType<typeof rawTask>
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nestedRecord(value: unknown, key: string): Record<string, unknown> | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const child = value[key];
|
||||
return isPlainRecord(child) ? child : null;
|
||||
}
|
||||
|
||||
function nestedBoolean(value: unknown, key: string): boolean | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const child = value[key];
|
||||
return typeof child === "boolean" ? child : null;
|
||||
}
|
||||
|
||||
function nestedString(value: unknown, key: string): string | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const child = value[key];
|
||||
return typeof child === "string" ? child : null;
|
||||
}
|
||||
|
||||
function jsonBodyFromText(bodyText: string, contentType: string): JsonValue {
|
||||
if (!contentTypeIsJson(contentType)) return bodyText.length > 0 ? truncateText(bodyText, 4000) : null;
|
||||
try {
|
||||
return compactJson(JSON.parse(bodyText) as unknown);
|
||||
} catch {
|
||||
return bodyText.length > 0 ? truncateText(bodyText, 4000) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function healthFailure(body: JsonValue, checks: Record<string, JsonValue>, reason: string): MicroserviceHealthAssessment {
|
||||
return { healthy: false, reason, checks, body };
|
||||
}
|
||||
|
||||
function healthSuccess(body: JsonValue, checks: Record<string, JsonValue>, reason = "health endpoint returned a usable service state"): MicroserviceHealthAssessment {
|
||||
return { healthy: true, reason, checks, body };
|
||||
}
|
||||
|
||||
function genericMicroserviceHealthAssessment(upstreamStatus: number, body: JsonValue): MicroserviceHealthAssessment {
|
||||
const checks: Record<string, JsonValue> = {
|
||||
upstreamStatus,
|
||||
upstream2xx: upstreamStatus >= 200 && upstreamStatus < 300,
|
||||
};
|
||||
if (upstreamStatus < 200 || upstreamStatus >= 300) {
|
||||
return healthFailure(body, checks, `health endpoint returned HTTP ${upstreamStatus}`);
|
||||
}
|
||||
if (isPlainRecord(body)) {
|
||||
const ok = body.ok;
|
||||
const success = body.success;
|
||||
const statusText = typeof body.status === "string" ? body.status : "";
|
||||
checks.okField = typeof ok === "boolean" ? ok : null;
|
||||
checks.successField = typeof success === "boolean" ? success : null;
|
||||
checks.statusField = statusText || null;
|
||||
if (ok === false) return healthFailure(body, checks, "health body ok=false");
|
||||
if (success === false) return healthFailure(body, checks, "health body success=false");
|
||||
if (["error", "failed", "fail", "unhealthy", "down", "offline", "not_ready", "not-ready"].includes(statusText.toLowerCase())) {
|
||||
return healthFailure(body, checks, `health body status=${statusText}`);
|
||||
}
|
||||
}
|
||||
return healthSuccess(body, checks);
|
||||
}
|
||||
|
||||
function assessMetNonlinearHealth(upstreamStatus: number, body: JsonValue): MicroserviceHealthAssessment {
|
||||
const base = genericMicroserviceHealthAssessment(upstreamStatus, body);
|
||||
const checks = { ...base.checks };
|
||||
if (!base.healthy) return { ...base, checks };
|
||||
if (!isPlainRecord(body)) return healthFailure(body, checks, "MET Nonlinear health body is not JSON");
|
||||
checks.service = typeof body.service === "string" ? body.service : null;
|
||||
checks.queueReady = isPlainRecord(body.queue);
|
||||
const image = nestedRecord(body, "image");
|
||||
checks.mlImage = typeof image?.image === "string" ? image.image : null;
|
||||
checks.mlImagePresent = image?.present === true;
|
||||
if (body.service !== "met-nonlinear-unidesk-ts") {
|
||||
return healthFailure(body, checks, "MET Nonlinear health returned an unexpected service identity");
|
||||
}
|
||||
if (!isPlainRecord(body.queue)) return healthFailure(body, checks, "MET Nonlinear queue state is not available");
|
||||
if (image?.present !== true) return healthFailure(body, checks, "MET Nonlinear ML image is not ready");
|
||||
return healthSuccess(body, checks, "MET Nonlinear backend, queue, and ML image are ready");
|
||||
}
|
||||
|
||||
function assessClaudeQqHealth(upstreamStatus: number, body: JsonValue): MicroserviceHealthAssessment {
|
||||
const base = genericMicroserviceHealthAssessment(upstreamStatus, body);
|
||||
const checks = { ...base.checks };
|
||||
if (!base.healthy) return { ...base, checks };
|
||||
if (!isPlainRecord(body)) return healthFailure(body, checks, "ClaudeQQ health body is not JSON");
|
||||
const napcat = nestedRecord(body, "napcat");
|
||||
const endpoints = Array.isArray(body.endpoints) ? body.endpoints.map((item) => String(item)) : [];
|
||||
const httpConnected = nestedBoolean(napcat, "httpConnected") === true;
|
||||
const wsConnected = nestedBoolean(napcat, "wsConnected") === true;
|
||||
const napcatConnected = nestedBoolean(napcat, "connected") === true;
|
||||
const loginState = nestedString(napcat, "loginState") ?? "";
|
||||
checks.service = typeof body.service === "string" ? body.service : null;
|
||||
checks.pushEndpoint = endpoints.includes("/api/push/text");
|
||||
checks.napcatHttpLoggedIn = httpConnected;
|
||||
checks.napcatWebsocketConnected = wsConnected;
|
||||
checks.napcatConnected = napcatConnected;
|
||||
checks.loginState = loginState || null;
|
||||
if (body.service !== "claudeqq") return healthFailure(body, checks, "ClaudeQQ health returned an unexpected service identity");
|
||||
if (!endpoints.includes("/api/push/text")) return healthFailure(body, checks, "ClaudeQQ push API endpoint is not advertised");
|
||||
if (!httpConnected) return healthFailure(body, checks, "NapCat HTTP is not logged in");
|
||||
if (!wsConnected) return healthFailure(body, checks, "NapCat OneBot WebSocket is not connected");
|
||||
if (!napcatConnected || loginState !== "logged_in") return healthFailure(body, checks, "NapCat is not in a logged-in send/receive ready state");
|
||||
return healthSuccess(body, checks, "ClaudeQQ and NapCat are logged in and send/receive ready");
|
||||
}
|
||||
|
||||
function assessMicroserviceHealth(service: MicroserviceConfig, upstreamStatus: number, contentType: string, bodyText: string): MicroserviceHealthAssessment {
|
||||
const body = jsonBodyFromText(bodyText, contentType);
|
||||
if (service.id === "met-nonlinear") return assessMetNonlinearHealth(upstreamStatus, body);
|
||||
if (service.id === "claudeqq") return assessClaudeQqHealth(upstreamStatus, body);
|
||||
return genericMicroserviceHealthAssessment(upstreamStatus, body);
|
||||
}
|
||||
|
||||
function healthProbeFromAssessment(
|
||||
service: MicroserviceConfig,
|
||||
upstreamStatus: number,
|
||||
contentType: string,
|
||||
assessment: MicroserviceHealthAssessment,
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
serviceId: service.id,
|
||||
name: service.name,
|
||||
providerId: service.providerId,
|
||||
healthPath: service.backend.healthPath,
|
||||
healthy: assessment.healthy,
|
||||
status: assessment.healthy ? "healthy" : "unhealthy",
|
||||
reason: assessment.reason,
|
||||
upstreamStatus,
|
||||
contentType,
|
||||
checks: assessment.checks,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function rememberMicroserviceAvailability(serviceId: string, probe: Record<string, JsonValue>): void {
|
||||
microserviceAvailabilityCache.set(serviceId, { expiresAt: Date.now() + microserviceAvailabilityTtlMs, probe });
|
||||
}
|
||||
|
||||
function cachedMicroserviceAvailability(serviceId: string): Record<string, JsonValue> | null {
|
||||
const entry = microserviceAvailabilityCache.get(serviceId);
|
||||
if (entry === undefined) return null;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
microserviceAvailabilityCache.delete(serviceId);
|
||||
return null;
|
||||
}
|
||||
return entry.probe;
|
||||
}
|
||||
|
||||
async function strictMicroserviceHealthProbe(service: MicroserviceConfig, timeoutMs?: number): Promise<Record<string, JsonValue>> {
|
||||
const probeService = timeoutMs === undefined
|
||||
? service
|
||||
: { ...service, backend: { ...service.backend, timeoutMs } };
|
||||
const response = await fetchMicroserviceUpstreamResponse(probeService, "GET", service.backend.healthPath, { query: "", jsonArrayLimits: {} }, { accept: "application/json" }, "");
|
||||
const upstreamStatus = response.status;
|
||||
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
||||
const bodyText = await response.text();
|
||||
const assessment = assessMicroserviceHealth(service, upstreamStatus, contentType, bodyText);
|
||||
const probe = healthProbeFromAssessment(service, upstreamStatus, contentType, assessment);
|
||||
rememberMicroserviceAvailability(service.id, probe);
|
||||
return { ...probe, body: assessment.body };
|
||||
}
|
||||
|
||||
async function strictMicroserviceHealthResponse(service: MicroserviceConfig, headOnly: boolean): Promise<Response> {
|
||||
const response = await fetchMicroserviceUpstreamResponse(service, "GET", service.backend.healthPath, { query: "", jsonArrayLimits: {} }, { accept: "application/json" }, "");
|
||||
const upstreamStatus = response.status;
|
||||
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
||||
const bodyText = await response.text();
|
||||
const assessment = assessMicroserviceHealth(service, upstreamStatus, contentType, bodyText);
|
||||
const probe = healthProbeFromAssessment(service, upstreamStatus, contentType, assessment);
|
||||
rememberMicroserviceAvailability(service.id, probe);
|
||||
if (assessment.healthy) {
|
||||
return new Response(headOnly ? null : bodyText, {
|
||||
status: upstreamStatus,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
"x-unidesk-health": "healthy",
|
||||
"x-unidesk-health-reason": assessment.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
const status = upstreamStatus >= 500 ? upstreamStatus : 503;
|
||||
return jsonResponse({
|
||||
ok: false,
|
||||
status: "unhealthy",
|
||||
serviceId: service.id,
|
||||
name: service.name,
|
||||
providerId: service.providerId,
|
||||
reason: assessment.reason,
|
||||
checkedAt: probe.checkedAt,
|
||||
checks: assessment.checks,
|
||||
upstream: {
|
||||
status: upstreamStatus,
|
||||
contentType,
|
||||
body: assessment.body,
|
||||
},
|
||||
}, status);
|
||||
}
|
||||
|
||||
async function microserviceAvailability(service: MicroserviceConfig): Promise<Record<string, JsonValue>> {
|
||||
const cached = cachedMicroserviceAvailability(service.id);
|
||||
if (cached !== null) return cached;
|
||||
const existing = microserviceAvailabilityRefreshes.get(service.id);
|
||||
if (existing !== undefined) return existing;
|
||||
const timeoutMs = Math.max(2500, Math.min(service.backend.timeoutMs, 10_000));
|
||||
const refresh = strictMicroserviceHealthProbe(service, timeoutMs)
|
||||
.catch((error) => {
|
||||
const probe = {
|
||||
serviceId: service.id,
|
||||
name: service.name,
|
||||
providerId: service.providerId,
|
||||
healthPath: service.backend.healthPath,
|
||||
healthy: false,
|
||||
status: "unhealthy",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
upstreamStatus: null,
|
||||
contentType: null,
|
||||
checks: {},
|
||||
checkedAt: new Date().toISOString(),
|
||||
} satisfies Record<string, JsonValue>;
|
||||
rememberMicroserviceAvailability(service.id, probe);
|
||||
return probe;
|
||||
})
|
||||
.finally(() => {
|
||||
microserviceAvailabilityRefreshes.delete(service.id);
|
||||
});
|
||||
microserviceAvailabilityRefreshes.set(service.id, refresh);
|
||||
return refresh;
|
||||
}
|
||||
|
||||
async function getMicroserviceAvailabilitySummary(): Promise<Record<string, JsonValue>> {
|
||||
const probes = await Promise.all(config.microservices.map((service) => microserviceAvailability(service)));
|
||||
const healthy = probes.filter((probe) => probe.healthy === true);
|
||||
const byProvider: Record<string, JsonValue> = {};
|
||||
for (const probe of probes) {
|
||||
const providerId = String(probe.providerId ?? "unknown");
|
||||
const current = isPlainRecord(byProvider[providerId]) ? byProvider[providerId] as Record<string, JsonValue> : { total: 0, healthy: 0, unhealthy: 0 };
|
||||
current.total = Number(current.total ?? 0) + 1;
|
||||
if (probe.healthy === true) current.healthy = Number(current.healthy ?? 0) + 1;
|
||||
else current.unhealthy = Number(current.unhealthy ?? 0) + 1;
|
||||
byProvider[providerId] = current;
|
||||
}
|
||||
return {
|
||||
totalCount: probes.length,
|
||||
healthyCount: healthy.length,
|
||||
unhealthyCount: probes.length - healthy.length,
|
||||
checkedAt: new Date().toISOString(),
|
||||
byProvider,
|
||||
services: probes.map((probe) => ({
|
||||
serviceId: probe.serviceId,
|
||||
name: probe.name,
|
||||
providerId: probe.providerId,
|
||||
healthy: probe.healthy,
|
||||
status: probe.status,
|
||||
reason: probe.reason,
|
||||
checkedAt: probe.checkedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function microserviceCacheTtlMs(serviceId: string, targetPath: string): number {
|
||||
if (targetPath === "/health") return 0;
|
||||
if (serviceId === "pipeline" && targetPath === "/api/snapshot") return 6_000;
|
||||
if (serviceId === "pipeline" && targetPath.startsWith("/api/oa-event-flow/")) return 20_000;
|
||||
if (serviceId === "pipeline" && targetPath.startsWith("/api/model-quota/")) return 60_000;
|
||||
@@ -2487,6 +2797,7 @@ function microserviceCacheStaleMs(serviceId: string, targetPath: string): number
|
||||
}
|
||||
|
||||
function providerMicroserviceCacheTtlMs(serviceId: string, targetPath: string): number {
|
||||
if (targetPath === "/health") return 0;
|
||||
if (serviceId === "met-nonlinear" && (targetPath === "/api/images" || targetPath === "/api/projects")) return 60_000;
|
||||
if (serviceId === "met-nonlinear" && targetPath === "/api/history") return 10_000;
|
||||
if (serviceId === "met-nonlinear" && (targetPath === "/api/queue" || targetPath === "/api/summary")) return 3_000;
|
||||
@@ -2695,6 +3006,7 @@ async function microserviceRoute(req: Request, url: URL): Promise<Response> {
|
||||
if (!isMicroservicePathAllowed(service, targetPath)) {
|
||||
return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403);
|
||||
}
|
||||
if (suffix === "health") return strictMicroserviceHealthResponse(service, method === "HEAD");
|
||||
const proxyOptions = readMicroserviceArrayLimits(url);
|
||||
const cacheKey = microserviceCacheKey(service, method, targetPath, proxyOptions);
|
||||
const cacheTtlMs = microserviceCacheTtlMs(service.id, targetPath);
|
||||
|
||||
@@ -619,6 +619,10 @@ function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) {
|
||||
const pendingCount = overview.pendingTaskCount ?? pendingTasks.length;
|
||||
const recentTasks = data.tasks.slice(0, 5);
|
||||
const pgdata = overview.pgdata || {};
|
||||
const microserviceAvailability = overview.microserviceAvailability || {};
|
||||
const microserviceTotal = asNumber(microserviceAvailability.totalCount);
|
||||
const microserviceHealthy = asNumber(microserviceAvailability.healthyCount);
|
||||
const microserviceUnhealthy = asNumber(microserviceAvailability.unhealthyCount);
|
||||
return h("div", { className: "page-grid overview-grid", "data-testid": "overview-page" },
|
||||
h(Panel, { title: "核心指标", eyebrow: "Control" },
|
||||
h("div", { className: "metric-grid" },
|
||||
@@ -626,6 +630,7 @@ function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) {
|
||||
h(MetricCard, { label: "PGDATA", value: fmtBytes(pgdata.databaseBytes), hint: `${pgdata.volumeName || "unidesk_pgdata_10gb"} / ${pgdata.databasePretty || "--"}`, tone: "ok", testId: "pgdata-usage-card" }),
|
||||
h(MetricCard, { label: "在线节点", value: overview.onlineNodeCount ?? 0, hint: `${overview.nodeCount ?? 0} registered`, tone: "ok" }),
|
||||
h(MetricCard, { label: "WebSocket", value: overview.activeSocketCount ?? 0, hint: "Provider ingress sockets" }),
|
||||
h(MetricCard, { label: "用户服务可用", value: microserviceTotal > 0 ? `${microserviceHealthy}/${microserviceTotal}` : "--", hint: microserviceTotal > 0 ? `healthyCount ${microserviceHealthy} · unhealthyCount ${microserviceUnhealthy}` : "strict /health probes", tone: microserviceTotal > 0 && microserviceUnhealthy === 0 ? "ok" : "warn", testId: "microservice-availability-card" }),
|
||||
h(MetricCard, { label: "待处理任务", value: pendingCount, hint: pendingCount > 0 ? "点击查看具体任务" : `timeout ${fmtDuration(Math.floor((overview.taskPendingTimeoutMs ?? 0) / 1000))}`, tone: pendingCount > 0 ? "warn" : "ok", onClick: () => onNavigate("tasks", "pending"), testId: "pending-task-card" }),
|
||||
),
|
||||
),
|
||||
@@ -1608,6 +1613,8 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
const availability = runtime.availability || {};
|
||||
const availabilityStatus = availability.status || (runtime.providerStatus === "online" ? "unknown" : "unhealthy");
|
||||
return h("tr", { key: service.id, "data-testid": `microservice-row-${safeId(service.id)}` },
|
||||
h("td", null, h("strong", null, service.name), h("code", null, service.id)),
|
||||
h("td", null, h("strong", null, runtime.providerName || service.providerId), h("code", null, service.providerId)),
|
||||
@@ -1618,7 +1625,11 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
|
||||
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"} -> ${backend.proxyMode || "--"}`),
|
||||
),
|
||||
h("td", null, h("span", null, service.development?.sshPassthrough ? "SSH 透传" : "未配置"), h("code", null, service.development?.worktreePath || "--")),
|
||||
h("td", null, h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"), h(DataSummary, { data: runtime.container, empty: "容器快照未上报" })),
|
||||
h("td", null,
|
||||
h(StatusBadge, { status: availabilityStatus === "healthy" ? "online" : availabilityStatus === "unknown" ? "warn" : "failed" }, availabilityStatus),
|
||||
h("span", null, availability.reason || runtime.providerStatus || "unknown"),
|
||||
h(DataSummary, { data: runtime.container, empty: "容器快照未上报" }),
|
||||
),
|
||||
h("td", null,
|
||||
h("div", { className: "microservice-actions" },
|
||||
service.id === "findjob" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "findjob"), "data-testid": "open-findjob-button" }, "打开") : null,
|
||||
|
||||
@@ -22,6 +22,16 @@ async function requestJson(path: string, options: AnyRecord = {}): Promise<any>
|
||||
return requestUniDeskJson(path, { failureFields: ["ok", "success"], ...options });
|
||||
}
|
||||
|
||||
async function requestHealthJson(path: string): Promise<any> {
|
||||
const response = await fetch(path, { credentials: "same-origin" });
|
||||
const text = await response.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : { ok: response.ok, status: response.status };
|
||||
} catch {
|
||||
return { ok: response.ok, status: response.status, text };
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||
@@ -119,7 +129,7 @@ export function ClaudeQqPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
|
||||
try {
|
||||
const [health, status, events, subscriptions, sent] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/claudeqq/health`),
|
||||
requestHealthJson(`${apiBaseUrl}/microservices/claudeqq/health`),
|
||||
requestJson(claudeqqApi(apiBaseUrl, "/api/server/status")),
|
||||
requestJson(claudeqqApi(apiBaseUrl, "/api/events/recent?limit=60")),
|
||||
requestJson(claudeqqApi(apiBaseUrl, "/api/events/subscriptions")),
|
||||
|
||||
Reference in New Issue
Block a user