418 lines
22 KiB
TypeScript
418 lines
22 KiB
TypeScript
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
Bot,
|
||
CakeSlice,
|
||
Candy,
|
||
CircleGauge,
|
||
Info,
|
||
MoonStar,
|
||
RotateCw,
|
||
ServerCog,
|
||
Sparkles,
|
||
Workflow
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import { platformApiClient } from "../api/client";
|
||
import type {
|
||
AiProviderResponse,
|
||
AuditEventResponse,
|
||
JobResponse,
|
||
PlatformResourceUsageResponse,
|
||
RunEndpointResponse,
|
||
ServerInstanceResponse,
|
||
ServerMetricsResponse
|
||
} from "../api/types";
|
||
import { UsageMeter } from "../components/OperationControls";
|
||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
|
||
import { serverIsOnline } from "../contracts/workspace";
|
||
import { cx } from "../utils/classes";
|
||
|
||
interface OverviewData {
|
||
instances: ServerInstanceResponse[];
|
||
endpoints: RunEndpointResponse[];
|
||
jobs: JobResponse[];
|
||
}
|
||
|
||
export interface HomePageInitialState {
|
||
core?: OperationsModuleState<OverviewData>;
|
||
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
|
||
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
|
||
providers?: OperationsModuleState<AiProviderResponse[]>;
|
||
signals?: OperationsModuleState<AuditEventResponse[]>;
|
||
}
|
||
|
||
interface HomePageProps extends PageComponentProps {
|
||
initialState?: HomePageInitialState;
|
||
}
|
||
|
||
export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
||
const [core, setCore] = useState<OperationsModuleState<OverviewData>>(initialState?.core ?? { status: "loading" });
|
||
const [metrics, setMetrics] = useState<OperationsModuleState<ServerMetricsResponse[]>>(initialState?.metrics ?? { status: "loading" });
|
||
const [usage, setUsage] = useState<OperationsModuleState<PlatformResourceUsageResponse>>(initialState?.usage ?? { status: "loading" });
|
||
const [providers, setProviders] = useState<OperationsModuleState<AiProviderResponse[]>>(initialState?.providers ?? { status: "loading" });
|
||
const [signals, setSignals] = useState<OperationsModuleState<AuditEventResponse[]>>(initialState?.signals ?? { status: "loading" });
|
||
|
||
const refreshCore = useCallback(async () => {
|
||
setCore({ status: "loading" });
|
||
try {
|
||
const [instances, endpoints, jobs] = await Promise.all([
|
||
platformApiClient.listServerInstances(),
|
||
platformApiClient.listRunEndpoints(),
|
||
platformApiClient.listJobs()
|
||
]);
|
||
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items }, refreshedAt: refreshedNow() });
|
||
} catch (error) {
|
||
setCore({ status: "error", reason: errorMessage(error, "服务器、节点或任务加载失败"), diagnosticId: "overview-core" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshMetrics = useCallback(async () => {
|
||
setMetrics({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listServerMetrics();
|
||
setMetrics({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
|
||
} catch (error) {
|
||
setMetrics({ status: "error", reason: errorMessage(error, "服务器指标加载失败"), diagnosticId: "overview-server-metrics" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshUsage = useCallback(async () => {
|
||
setUsage({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.getPlatformResourceUsage();
|
||
setUsage({ status: "ready", data: response, refreshedAt: refreshedNow() });
|
||
} catch (error) {
|
||
setUsage({ status: "error", reason: errorMessage(error, "平台资源指标加载失败"), diagnosticId: "overview-platform-usage" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshProviders = useCallback(async () => {
|
||
setProviders({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listAiProviders();
|
||
setProviders({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
|
||
} catch (error) {
|
||
setProviders({ status: "error", reason: errorMessage(error, "AI 提供商状态加载失败"), diagnosticId: "overview-ai-providers" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshSignals = useCallback(async () => {
|
||
setSignals({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listAuditEvents();
|
||
setSignals({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
|
||
} catch (error) {
|
||
setSignals({ status: "error", reason: errorMessage(error, "审计事件加载失败"), diagnosticId: "overview-audit-events" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshAll = useCallback(() => {
|
||
void refreshCore();
|
||
void refreshMetrics();
|
||
void refreshUsage();
|
||
void refreshProviders();
|
||
void refreshSignals();
|
||
}, [refreshCore, refreshMetrics, refreshProviders, refreshSignals, refreshUsage]);
|
||
|
||
useEffect(() => {
|
||
if (initialState) {
|
||
return;
|
||
}
|
||
refreshAll();
|
||
}, [initialState, refreshAll]);
|
||
|
||
const distribution = useMemo<GameTypeDistributionEntry[]>(() => {
|
||
if (core.status !== "ready") {
|
||
return [];
|
||
}
|
||
const counts = new Map<string, number>();
|
||
for (const instance of core.data.instances) {
|
||
counts.set(instance.pluginId, (counts.get(instance.pluginId) ?? 0) + 1);
|
||
}
|
||
return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count }));
|
||
}, [core]);
|
||
|
||
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers, signals), [core, providers, signals]);
|
||
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
|
||
const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null;
|
||
const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0;
|
||
const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
|
||
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0;
|
||
const errorProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "error").length : 0;
|
||
const moduleFailureCount = [core, metrics, usage, providers, signals].filter((module) => module.status === "error").length;
|
||
const canManageServers = session.capabilities.includes("servers.manage");
|
||
|
||
const metricAverages = useMemo(() => {
|
||
if (usage.status === "ready") {
|
||
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标", refreshedAt: usage.refreshedAt };
|
||
}
|
||
if (metrics.status === "ready" && metrics.data.length > 0) {
|
||
const average = (values: Array<number | undefined>) => {
|
||
const known = values.filter((value): value is number => typeof value === "number");
|
||
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) / known.length : undefined;
|
||
};
|
||
return {
|
||
cpu: average(metrics.data.map((item) => item.cpuPercent)),
|
||
memory: average(metrics.data.map((item) => item.memoryPercent)),
|
||
disk: average(metrics.data.map((item) => item.diskPercent)),
|
||
source: "服务器指标均值",
|
||
refreshedAt: metrics.refreshedAt
|
||
};
|
||
}
|
||
return { cpu: undefined, memory: undefined, disk: undefined, source: metrics.status === "error" || usage.status === "error" ? "指标不可用" : "等待指标", refreshedAt: undefined };
|
||
}, [usage, metrics]);
|
||
|
||
return (
|
||
<div className="console-page operations-overview-page">
|
||
<header className="page-header">
|
||
<div>
|
||
<p className="page-kicker">运营指挥</p>
|
||
<h1 className="page-title">平台概览</h1>
|
||
</div>
|
||
<div className="page-header-actions">
|
||
<span className={cx("page-status", moduleFailureCount === 0 && core.status === "ready" && "page-status-ready")}>
|
||
{moduleFailureCount > 0 ? `${moduleFailureCount} 个模块不可用` : core.status === "ready" ? "运营数据已同步" : "正在同步"}
|
||
</span>
|
||
<button type="button" className="icon-command" onClick={refreshAll}>
|
||
<RotateCw size={14} />
|
||
全部刷新
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{core.status === "loading" && <LoadingState label="正在加载服务器、节点与任务概况…" />}
|
||
{core.status === "error" && <ErrorState title="平台核心概况不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} />}
|
||
{core.status === "ready" && core.data.instances.length === 0 && (
|
||
<EmptyState
|
||
icon={<CakeSlice size={26} />}
|
||
title="还没有服务器实例"
|
||
description={canManageServers ? "平台尚未创建服务器。先确认运行节点在线,再创建第一个实例。" : "当前账号可查看概览,但没有创建服务器的权限。"}
|
||
actionLabel="前往服务器管理"
|
||
onAction={() => onNavigate("servers")}
|
||
/>
|
||
)}
|
||
|
||
{core.status === "ready" && (
|
||
<section className="console-grid operations-summary-grid" aria-label="平台运营摘要">
|
||
<article className="overview-card metric-tone-success">
|
||
<span className="metric-label">在线服务器</span>
|
||
<strong className="metric-value">{onlineCount}</strong>
|
||
<p>共 {core.data.instances.length} 个实例</p>
|
||
</article>
|
||
<article className={cx("overview-card", offlineCount > 0 ? "metric-tone-warning" : "metric-tone-neutral")}>
|
||
<span className="metric-label">离线 / 未运行</span>
|
||
<strong className="metric-value">{offlineCount}</strong>
|
||
<p>{core.data.instances.filter((item) => item.state === "failed").length} 个异常</p>
|
||
</article>
|
||
<article className={cx("overview-card", endpointSummary && endpointSummary.degraded + endpointSummary.offline > 0 ? "metric-tone-warning" : "metric-tone-neutral")}>
|
||
<span className="metric-label">运行节点</span>
|
||
<strong className="metric-value">{endpointSummary?.online ?? 0} 在线</strong>
|
||
<p>{endpointSummary ? `${endpointSummary.degraded} 降级,${endpointSummary.offline} 离线` : "节点状态不可用"}</p>
|
||
</article>
|
||
<article className={cx("overview-card", providers.status === "error" || errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
|
||
<span className="metric-label">AI 提供商</span>
|
||
<strong className="metric-value">{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "加载中" : "不可用"}</strong>
|
||
<p>{providers.status === "error" ? "提供商状态接口失败" : errorProviders > 0 ? `${errorProviders} 个连接异常` : "连接状态正常"}</p>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
<section className="operations-command-grid" aria-label="平台运营模块">
|
||
<article className="console-panel console-module" aria-label="任务脉冲">
|
||
<div className="panel-header">
|
||
<h2><Workflow size={16} /> 任务脉冲</h2>
|
||
<span className="page-status">{moduleFreshnessLabel(core.status === "ready" ? core.refreshedAt : core.refreshedAt)}</span>
|
||
</div>
|
||
{core.status === "loading" && <LoadingState label="正在汇总任务…" compact />}
|
||
{core.status === "error" && <ErrorState title="任务状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
|
||
{core.status === "ready" && jobs && (
|
||
<>
|
||
<dl className="console-stat-strip">
|
||
<div><dt>执行中</dt><dd>{jobs.active.length}</dd></div>
|
||
<div><dt>等待重试</dt><dd>{jobs.retrying.length}</dd></div>
|
||
<div><dt>近期失败</dt><dd>{jobs.failed.length}</dd></div>
|
||
</dl>
|
||
{jobs.active.length === 0 && jobs.failed.length === 0 ? (
|
||
<p className="console-empty-note">当前没有执行中或失败任务。</p>
|
||
) : (
|
||
<div className="console-row-list">
|
||
{[...jobs.active.slice(0, 3), ...jobs.failed.slice(0, 2)].map((job) => (
|
||
<button
|
||
key={job.id}
|
||
type="button"
|
||
className={cx("console-row console-row-button", job.state === "failed" && "console-row-danger")}
|
||
onClick={() => onNavigate(job.serverInstanceId ? "serverDetail" : "maintenance", job.serverInstanceId ? { serverId: job.serverInstanceId } : undefined)}
|
||
>
|
||
<span><strong>{job.capability}</strong><small>{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"}</small></span>
|
||
<span>{job.state === "retrying" ? `重试 ${job.attempt}/${job.retryPolicy.maxAttempts}` : jobStateLabel(job.state)}</span>
|
||
<span>{Math.round(job.progress.percent)}%</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
|
||
<article className="console-panel console-module" aria-label="运行节点状态">
|
||
<div className="panel-header">
|
||
<h2><ServerCog size={16} /> 运行节点</h2>
|
||
<button type="button" className="icon-command" onClick={() => onNavigate("maintenance")}><CircleGauge size={14} /> 排障</button>
|
||
</div>
|
||
{core.status === "loading" && <LoadingState label="正在汇总节点…" compact />}
|
||
{core.status === "error" && <ErrorState title="节点状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
|
||
{core.status === "ready" && endpointSummary && (
|
||
<>
|
||
<dl className="console-stat-strip">
|
||
<div><dt>在线</dt><dd>{endpointSummary.online}</dd></div>
|
||
<div><dt>活跃任务</dt><dd>{endpointSummary.activeJobs}</dd></div>
|
||
<div><dt>排队</dt><dd>{endpointSummary.queuedJobs}</dd></div>
|
||
</dl>
|
||
{core.data.endpoints.length === 0 ? (
|
||
<p className="console-empty-note">Platform 未返回运行节点。</p>
|
||
) : (
|
||
<div className="console-row-list">
|
||
{core.data.endpoints.slice(0, 4).map((endpoint) => (
|
||
<div key={endpoint.id} className="console-row">
|
||
<span><strong>{endpoint.displayName}</strong><small>{endpoint.version}</small></span>
|
||
<span className={cx("status-pill", `status-${endpoint.status}`)}>{endpointStatusLabel(endpoint.status)}</span>
|
||
<span>{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} 运行</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
</section>
|
||
|
||
<ProductionGovernancePanel compact title="生产容量与告警" />
|
||
|
||
<section className="overview-two-col">
|
||
<article className="console-panel" aria-label="resource usage">
|
||
<div className="panel-header">
|
||
<h2><Activity size={16} /> 资源负载</h2>
|
||
<span className="page-status">{metricAverages.source}</span>
|
||
</div>
|
||
{usage.status === "loading" && metrics.status === "loading" ? (
|
||
<LoadingState label="正在读取资源指标…" compact />
|
||
) : usage.status === "error" && metrics.status === "error" ? (
|
||
<ErrorState title="资源指标不可用" reason={`${usage.reason};${metrics.reason}`} diagnosticId="overview-resource-usage" onRetry={() => { void refreshUsage(); void refreshMetrics(); }} compact />
|
||
) : (
|
||
<>
|
||
<div className="server-card-meters">
|
||
<UsageMeter label="CPU" percent={metricAverages.cpu} />
|
||
<UsageMeter label="内存" percent={metricAverages.memory} />
|
||
<UsageMeter label="磁盘" percent={metricAverages.disk} />
|
||
</div>
|
||
<span className="console-note">{moduleFreshnessLabel(metricAverages.refreshedAt)}</span>
|
||
{(usage.status === "error" || metrics.status === "error") && (
|
||
<button type="button" className="operations-inline-warning" onClick={() => { void refreshUsage(); void refreshMetrics(); }}>
|
||
<AlertTriangle size={14} /> 部分指标源不可用,点击重试
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
|
||
<article className="console-panel" aria-label="game type distribution">
|
||
<div className="panel-header"><h2><Candy size={16} /> 游戏类型分布</h2></div>
|
||
{core.status === "loading" ? (
|
||
<LoadingState label="统计中…" compact />
|
||
) : core.status === "error" ? (
|
||
<ErrorState title="分布数据不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />
|
||
) : distribution.length === 0 ? (
|
||
<EmptyState title="暂无分布数据" description="创建服务器后这里会显示各游戏类型的实例数量。" />
|
||
) : (
|
||
<div className="action-list">
|
||
{distribution.map((entry) => <span key={entry.serverType}><strong>{entry.label}</strong>:{entry.count} 个实例</span>)}
|
||
</div>
|
||
)}
|
||
</article>
|
||
</section>
|
||
|
||
<section className="console-panel" aria-label="recent signals">
|
||
<div className="panel-header">
|
||
<h2><MoonStar size={16} /> 最近运营信号</h2>
|
||
<div className="panel-header-actions">
|
||
<span className="page-status">{moduleFreshnessLabel(signals.status === "ready" ? signals.refreshedAt : signals.refreshedAt)}</span>
|
||
<button type="button" className="icon-command" onClick={() => void refreshSignals()}><Sparkles size={14} /> 刷新审计</button>
|
||
</div>
|
||
</div>
|
||
{signals.status === "loading" && core.status === "loading" && <LoadingState label="正在收集信号…" compact />}
|
||
{signals.status === "error" && <ErrorState title="审计信号不可用" reason={signals.reason} diagnosticId={signals.diagnosticId} onRetry={() => void refreshSignals()} compact />}
|
||
{providers.status === "error" && <ErrorState title="AI 提供商信号不可用" reason={providers.reason} diagnosticId={providers.diagnosticId} onRetry={() => void refreshProviders()} compact />}
|
||
{overviewSignals.length === 0 && signals.status === "ready" && core.status === "ready" && providers.status === "ready" ? (
|
||
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或需要关注的审计事件。" />
|
||
) : (
|
||
<div className="signal-list">
|
||
{overviewSignals.map((signal) => (
|
||
<button
|
||
key={signal.id}
|
||
type="button"
|
||
className={cx("signal-item", `signal-tone-${signal.tone}`)}
|
||
onClick={() => onNavigate(signal.targetPage === "servers" && signal.targetId ? "serverDetail" : signal.targetPage, signal.targetPage === "servers" && signal.targetId ? { serverId: signal.targetId } : undefined)}
|
||
>
|
||
{signal.tone === "error" || signal.tone === "warning" ? <AlertTriangle size={18} /> : signal.kind === "aiProvider" ? <Bot size={18} /> : <Info size={18} />}
|
||
<span><strong>{signal.summary}</strong><p>{signal.detail}</p></span>
|
||
<span className="provider-id">{signal.at ? new Date(signal.at).toLocaleString() : ""}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function buildOverviewSignals(
|
||
core: OperationsModuleState<OverviewData>,
|
||
providers: OperationsModuleState<AiProviderResponse[]>,
|
||
signals: OperationsModuleState<AuditEventResponse[]>
|
||
): PlatformOverviewSignal[] {
|
||
const collected: PlatformOverviewSignal[] = [];
|
||
if (core.status === "ready") {
|
||
for (const instance of core.data.instances.filter((item) => item.state === "failed")) {
|
||
collected.push({ id: `fault-${instance.id}`, kind: "fault", summary: `服务器 ${instance.name} 处于异常状态`, detail: `实例 ${instance.id} 需要排查`, targetPage: "servers", targetId: instance.id, tone: "error", at: instance.updatedAt });
|
||
}
|
||
for (const job of jobBuckets(core.data.jobs).failed.slice(0, 5)) {
|
||
collected.push({ id: `job-${job.id}`, kind: "job", summary: `任务 ${job.capability} 失败`, detail: job.serverInstanceId ? `服务器 ${job.serverInstanceId} 的任务执行失败` : "平台任务执行失败", targetPage: job.serverInstanceId ? "servers" : "maintenance", targetId: job.serverInstanceId, tone: "error", at: job.updatedAt });
|
||
}
|
||
}
|
||
if (providers.status === "ready") {
|
||
for (const provider of providers.data.filter((item) => item.status === "error")) {
|
||
collected.push({ id: `ai-${provider.id}`, kind: "aiProvider", summary: `AI 提供商 ${provider.name} 连接异常`, detail: "LLM 辅助暂不可用,请在提供商管理中检查已保存配置。", targetPage: "aiProviders", targetId: provider.id, tone: "warning", at: "" });
|
||
}
|
||
}
|
||
if (signals.status === "ready") {
|
||
for (const event of signals.data.slice(0, 5)) {
|
||
collected.push({ id: `audit-${event.id}`, kind: "log", summary: event.summary || `${event.action} ${event.resourceKind}`, detail: `${event.resourceKind}/${event.resourceId}:${event.result}`, targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance", targetId: event.resourceId, tone: event.result === "failure" ? "warning" : "info", at: event.createdAt });
|
||
}
|
||
}
|
||
return collected.sort((left, right) => Date.parse(right.at || "") - Date.parse(left.at || "")).slice(0, 8);
|
||
}
|
||
|
||
function refreshedNow(): string {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function errorMessage(error: unknown, fallback: string): string {
|
||
return error instanceof Error ? error.message : fallback;
|
||
}
|
||
|
||
function jobStateLabel(state: JobResponse["state"]): string {
|
||
const labels: Record<JobResponse["state"], string> = { queued: "排队", accepted: "已领取", running: "执行中", retrying: "等待重试", succeeded: "完成", failed: "失败", cancelled: "已取消" };
|
||
return labels[state];
|
||
}
|
||
|
||
function endpointStatusLabel(status: RunEndpointResponse["status"]): string {
|
||
const labels: Record<RunEndpointResponse["status"], string> = { online: "在线", offline: "离线", degraded: "降级", disabled: "停用" };
|
||
return labels[status];
|
||
}
|