322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import { Activity, AlertTriangle, CakeSlice, Candy, Info, MoonStar, Sparkles } 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 { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
|
||
import { serverIsOnline } from "../contracts/workspace";
|
||
import { cx } from "../utils/classes";
|
||
|
||
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||
|
||
interface OverviewData {
|
||
instances: ServerInstanceResponse[];
|
||
endpoints: RunEndpointResponse[];
|
||
jobs: JobResponse[];
|
||
}
|
||
|
||
export function HomePage({ onNavigate }: PageComponentProps) {
|
||
const [core, setCore] = useState<ModuleState<OverviewData>>({ status: "loading" });
|
||
const [metrics, setMetrics] = useState<ModuleState<ServerMetricsResponse[]>>({ status: "loading" });
|
||
const [usage, setUsage] = useState<ModuleState<PlatformResourceUsageResponse>>({ status: "loading" });
|
||
const [providers, setProviders] = useState<ModuleState<AiProviderResponse[]>>({ status: "loading" });
|
||
const [signals, setSignals] = useState<ModuleState<AuditEventResponse[]>>({ 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 } });
|
||
} catch (error) {
|
||
setCore({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshMetrics = useCallback(async () => {
|
||
setMetrics({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listServerMetrics();
|
||
setMetrics({ status: "ready", data: response.items });
|
||
} catch {
|
||
setMetrics({ status: "ready", data: [] });
|
||
}
|
||
}, []);
|
||
|
||
const refreshUsage = useCallback(async () => {
|
||
setUsage({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.getPlatformResourceUsage();
|
||
setUsage({ status: "ready", data: response });
|
||
} catch {
|
||
setUsage({ status: "error", reason: "平台资源指标接口尚未提供" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshProviders = useCallback(async () => {
|
||
setProviders({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listAiProviders();
|
||
setProviders({ status: "ready", data: response.items });
|
||
} catch (error) {
|
||
setProviders({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||
}
|
||
}, []);
|
||
|
||
const refreshSignals = useCallback(async () => {
|
||
setSignals({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listAuditEvents();
|
||
setSignals({ status: "ready", data: response.items });
|
||
} catch {
|
||
setSignals({ status: "ready", data: [] });
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refreshCore();
|
||
void refreshMetrics();
|
||
void refreshUsage();
|
||
void refreshProviders();
|
||
void refreshSignals();
|
||
}, [refreshCore, refreshMetrics, refreshUsage, refreshProviders, refreshSignals]);
|
||
|
||
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[]>(() => {
|
||
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} 状态为 failed,需要排查`,
|
||
targetPage: "servers",
|
||
targetId: instance.id,
|
||
tone: "error",
|
||
at: instance.updatedAt
|
||
});
|
||
}
|
||
for (const job of core.data.jobs.filter((item) => item.state === "failed").slice(0, 5)) {
|
||
collected.push({
|
||
id: `job-${job.id}`,
|
||
kind: "job",
|
||
summary: `任务 ${job.capability} 失败`,
|
||
detail: `任务 ${job.id}${job.serverInstanceId ? `(服务器 ${job.serverInstanceId})` : ""} 执行失败`,
|
||
targetPage: "servers",
|
||
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: `提供商 ${provider.id} 状态为 error,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.actorId} 对 ${event.resourceKind}/${event.resourceId} 执行 ${event.action}(${event.result})`,
|
||
targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance",
|
||
targetId: event.resourceId,
|
||
tone: event.result === "failure" ? "warning" : "info",
|
||
at: event.createdAt
|
||
});
|
||
}
|
||
}
|
||
return collected.slice(0, 8);
|
||
}, [core, providers, signals]);
|
||
|
||
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 metricAverages = useMemo(() => {
|
||
if (usage.status === "ready") {
|
||
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标" };
|
||
}
|
||
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: "按服务器均值"
|
||
};
|
||
}
|
||
return { cpu: undefined, memory: undefined, disk: undefined, source: "暂无数据" };
|
||
}, [usage, metrics]);
|
||
|
||
return (
|
||
<div className="console-page">
|
||
<header className="page-header">
|
||
<div>
|
||
<p className="page-kicker">平台管理员</p>
|
||
<h1 className="page-title">平台概览</h1>
|
||
</div>
|
||
<span className={cx("page-status", core.status === "ready" && "page-status-ready")}>
|
||
{core.status === "ready" ? "数据已加载" : core.status === "loading" ? "加载中" : "部分模块加载失败"}
|
||
</span>
|
||
</header>
|
||
|
||
{core.status === "loading" && <LoadingState label="正在加载服务器与任务概况…" />}
|
||
{core.status === "error" && <ErrorState title="平台概况加载失败" reason={core.reason} diagnosticId="overview-core" onRetry={() => void refreshCore()} />}
|
||
{core.status === "ready" && core.data.instances.length === 0 && (
|
||
<EmptyState
|
||
icon={<CakeSlice size={26} />}
|
||
title="还没有服务器实例"
|
||
description="平台尚未创建任何服务器。前往服务器管理创建第一个实例,或检查运行节点是否在线。"
|
||
actionLabel="前往服务器管理"
|
||
onAction={() => onNavigate("servers")}
|
||
/>
|
||
)}
|
||
|
||
{core.status === "ready" && core.data.instances.length > 0 && (
|
||
<section className="console-grid" aria-label="platform health">
|
||
<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="overview-card metric-tone-neutral">
|
||
<span className="metric-label">运行节点</span>
|
||
<strong className="metric-value">{core.data.endpoints.filter((item) => item.status === "online").length}</strong>
|
||
<p>共 {core.data.endpoints.length} 个节点</p>
|
||
</article>
|
||
<article className={cx("overview-card", errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
|
||
<span className="metric-label">LLM 连接</span>
|
||
<strong className="metric-value">
|
||
{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "…" : "未知"}
|
||
</strong>
|
||
<p>{errorProviders > 0 ? `${errorProviders} 个提供商异常` : "提供商状态正常"}</p>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
<section className="overview-two-col">
|
||
<article className="console-panel" aria-label="resource usage">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<Activity size={16} style={{ verticalAlign: "-2px" }} /> 资源负载
|
||
</h2>
|
||
<span className="page-status">{metricAverages.source}</span>
|
||
</div>
|
||
<div className="server-card-meters">
|
||
<UsageMeter label="CPU" percent={metricAverages.cpu} />
|
||
<UsageMeter label="内存" percent={metricAverages.memory} />
|
||
<UsageMeter label="磁盘" percent={metricAverages.disk} />
|
||
</div>
|
||
</article>
|
||
|
||
<article className="console-panel" aria-label="game type distribution">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<Candy size={16} style={{ verticalAlign: "-2px" }} /> 游戏类型分布
|
||
</h2>
|
||
</div>
|
||
{core.status === "loading" ? (
|
||
<LoadingState label="统计中…" 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} style={{ verticalAlign: "-2px" }} /> 最近运营信号
|
||
</h2>
|
||
<button type="button" className="icon-command" onClick={() => void refreshSignals()}>
|
||
<Sparkles size={14} />
|
||
刷新
|
||
</button>
|
||
</div>
|
||
{signals.status === "loading" && core.status === "loading" ? (
|
||
<LoadingState label="正在收集信号…" compact />
|
||
) : overviewSignals.length === 0 ? (
|
||
<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" ? <AlertTriangle size={18} /> : signal.tone === "warning" ? <AlertTriangle 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>
|
||
);
|
||
}
|