Files
browser/platform_web/pages/HomePage.tsx
T

304 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Activity,
AlertTriangle,
Bot,
CakeSlice,
Candy,
Info,
MoonStar,
RotateCw,
Sparkles
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
AiProviderResponse,
JobResponse,
PlatformResourceUsageResponse,
ServerInstanceResponse,
ServerMetricsResponse
} from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { jobBuckets, moduleFreshnessLabel, type OperationsModuleState } from "../contracts/operationsConsole";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
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[];
jobs: JobResponse[];
}
export interface HomePageInitialState {
core?: OperationsModuleState<OverviewData>;
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
providers?: OperationsModuleState<AiProviderResponse[]>;
}
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 refreshCore = useCallback(async () => {
setCore({ status: "loading" });
try {
const [instances, jobs] = await Promise.all([
platformApiClient.listServerInstances(),
platformApiClient.listJobs()
]);
setCore({ status: "ready", data: { instances: instances.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 refreshAll = useCallback(() => {
void refreshCore();
void refreshMetrics();
void refreshUsage();
void refreshProviders();
}, [refreshCore, refreshMetrics, refreshProviders, 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), [core, providers]);
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].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]);
const resourceMetricsPending = usage.status === "loading" || metrics.status === "loading";
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 ? "平台尚未创建服务器。先创建第一个实例,再生成并启动 Run。" : "当前账号可查看概览,但没有创建服务器的权限。"}
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", 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="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} pending={resourceMetricsPending} />
<UsageMeter label="内存" percent={metricAverages.memory} pending={resourceMetricsPending} />
<UsageMeter label="磁盘" percent={metricAverages.disk} pending={resourceMetricsPending} />
</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(core.status === "ready" ? core.refreshedAt : providers.status === "ready" ? providers.refreshedAt : undefined)}</span>
<button type="button" className="icon-command" onClick={() => { void refreshCore(); void refreshProviders(); }}><Sparkles size={14} /> 刷新信号</button>
</div>
</div>
{core.status === "loading" && providers.status === "loading" && <LoadingState label="正在收集信号…" compact />}
{providers.status === "error" && <ErrorState title="AI 提供商信号不可用" reason={providers.reason} diagnosticId={providers.diagnosticId} onRetry={() => void refreshProviders()} compact />}
{overviewSignals.length === 0 && core.status === "ready" && providers.status === "ready" ? (
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或 AI 提供商异常。" />
) : (
<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[]>
): 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: `任务 ${jobCapabilityLabel(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: "" });
}
}
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;
}