refactor(panel): dashboard
This commit is contained in:
4 files changed
+498
-47
No files matched your search
@@ -34,8 +34,13 @@ export function VoxelFleet({ servers }: Props) {
|
|||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
mount.appendChild(renderer.domElement);
|
renderer.domElement.style.position = "absolute";
|
||||||
|
renderer.domElement.style.top = "0";
|
||||||
|
renderer.domElement.style.left = "0";
|
||||||
|
renderer.domElement.style.width = "100%";
|
||||||
|
renderer.domElement.style.height = "100%";
|
||||||
renderer.domElement.style.display = "block";
|
renderer.domElement.style.display = "block";
|
||||||
|
mount.appendChild(renderer.domElement);
|
||||||
|
|
||||||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||||||
const key = new THREE.DirectionalLight(0xffffff, 1.1);
|
const key = new THREE.DirectionalLight(0xffffff, 1.1);
|
||||||
@@ -157,7 +162,7 @@ export function VoxelFleet({ servers }: Props) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div ref={mountRef} className="h-full w-full" />;
|
return <div ref={mountRef} className="h-full w-full relative" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent
|
/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent
|
||||||
|
|||||||
@@ -10,5 +10,36 @@
|
|||||||
"fleet": "Fleet",
|
"fleet": "Fleet",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"loading_scene": "Loading scene…",
|
"loading_scene": "Loading scene…",
|
||||||
"preparing_scene": "Preparing scene…"
|
"preparing_scene": "Preparing scene…",
|
||||||
|
"fleet_load": "Fleet Load & Health",
|
||||||
|
"active_load": "Player Load Rate",
|
||||||
|
"status_distribution": "Node Status Distribution",
|
||||||
|
"account_status": "Account Status",
|
||||||
|
"account_linked_title": "Account Linked",
|
||||||
|
"account_linked_desc": "Your identity is bound to a Minecraft UUID. You have full server ownership and wake permissions.",
|
||||||
|
"account_unlinked_title": "Account Unlinked",
|
||||||
|
"account_unlinked_desc": "You must link a Minecraft character to claim or operate servers. Go to Account to bind now.",
|
||||||
|
"go_link": "Bind Now",
|
||||||
|
"system_specs": "Specs & Metrics",
|
||||||
|
"spec_images_total": "Whitelisted Images",
|
||||||
|
"spec_images_built": "Built Locally",
|
||||||
|
"spec_images_external": "External Whitelisted",
|
||||||
|
"spec_servers_total": "Total Servers",
|
||||||
|
"spec_servers_owned": "Owned Servers",
|
||||||
|
"spec_servers_claimable": "Claimable Servers",
|
||||||
|
"spec_running_ratio": "Running Rate",
|
||||||
|
"spec_stopped_ratio": "Sleeping Rate",
|
||||||
|
"spec_failed_ratio": "Failed Rate",
|
||||||
|
"no_servers_monitor": "No active instances in the cluster",
|
||||||
|
"images_count": "Whitelisted backend images available",
|
||||||
|
"legend_title": "3D Node Monitor Legend",
|
||||||
|
"legend_running": "Running",
|
||||||
|
"legend_starting": "Starting",
|
||||||
|
"legend_stopping": "Stopping",
|
||||||
|
"legend_stopped": "Sleeping",
|
||||||
|
"legend_failed": "Failed",
|
||||||
|
"legend_unknown": "Unknown",
|
||||||
|
"welcome_title": "Welcome to Felis Lolicon",
|
||||||
|
"welcome_desc": "You have logged into the User Dashboard. Go to 'My Servers' to view and claim nodes allocated to you, or manage their sleep, wake, and console logs.",
|
||||||
|
"go_to_servers_btn": "Manage My Servers"
|
||||||
}
|
}
|
||||||
@@ -10,5 +10,36 @@
|
|||||||
"fleet": "服务器概览",
|
"fleet": "服务器概览",
|
||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"loading_scene": "正在加载 3D 场景…",
|
"loading_scene": "正在加载 3D 场景…",
|
||||||
"preparing_scene": "正在准备场景…"
|
"preparing_scene": "正在准备场景…",
|
||||||
|
"fleet_load": "负载与健康度",
|
||||||
|
"active_load": "玩家在线负载",
|
||||||
|
"status_distribution": "节点状态分布",
|
||||||
|
"account_status": "账号绑定状态",
|
||||||
|
"account_linked_title": "已关联游戏身份",
|
||||||
|
"account_linked_desc": "您的 Web 身份已成功绑定至 Minecraft 角色。拥有完整的所有权认领及启动权限。",
|
||||||
|
"account_unlinked_title": "未关联游戏角色",
|
||||||
|
"account_unlinked_desc": "您需要关联 Minecraft 角色才能认领或操作服务器。请立即前往个人中心绑定。",
|
||||||
|
"go_link": "前往绑定",
|
||||||
|
"system_specs": "系统实例与镜像指标",
|
||||||
|
"spec_images_total": "支持镜像总数",
|
||||||
|
"spec_images_built": "集群自建镜像",
|
||||||
|
"spec_images_external": "外部登记镜像",
|
||||||
|
"spec_servers_total": "可见服务器总数",
|
||||||
|
"spec_servers_owned": "我拥有的服务器",
|
||||||
|
"spec_servers_claimable": "公网可认领服",
|
||||||
|
"spec_running_ratio": "节点运行率",
|
||||||
|
"spec_stopped_ratio": "节点休眠率",
|
||||||
|
"spec_failed_ratio": "节点故障率",
|
||||||
|
"no_servers_monitor": "当前集群暂无服务器实例",
|
||||||
|
"images_count": "个支持的白名单镜像环境",
|
||||||
|
"legend_title": "3D 节点监控图例",
|
||||||
|
"legend_running": "运行中",
|
||||||
|
"legend_starting": "启动中",
|
||||||
|
"legend_stopping": "停止中",
|
||||||
|
"legend_stopped": "已休眠",
|
||||||
|
"legend_failed": "异常失败",
|
||||||
|
"legend_unknown": "未知状态",
|
||||||
|
"welcome_title": "欢迎使用 Felis 编排平台",
|
||||||
|
"welcome_desc": "您已成功登录用户控制台。您可以在「我的服务器」页面查看并认领分配给您的专属节点,或者对其进行休眠、唤醒和控制台日志监控。",
|
||||||
|
"go_to_servers_btn": "操作我的服务器"
|
||||||
}
|
}
|
||||||
+427
-43
@@ -1,13 +1,25 @@
|
|||||||
import { lazy, Suspense, useMemo } from "react";
|
import { lazy, Suspense, useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Activity, Server, Play, Hand } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
Server,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
Cpu,
|
||||||
|
ShieldCheck,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Hand,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
import { Loading, ErrorState } from "@/components/States";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useAsync, useConfig } from "@/lib/hooks";
|
import { useAsync, useConfig } from "@/lib/hooks";
|
||||||
import type { Phase, ServerInfo } from "@/lib/types";
|
import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types";
|
||||||
|
|
||||||
// three.js is heavy and only the Dashboard renders it — split it into its own
|
// three.js is heavy and only the Dashboard renders it — split it into its own
|
||||||
// async chunk so the rest of the panel doesn't pay for it on first load.
|
// async chunk so the rest of the panel doesn't pay for it on first load.
|
||||||
@@ -41,18 +53,32 @@ function Stat({
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const cfg = useConfig();
|
const cfg = useConfig();
|
||||||
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
|
const { data: servers, error: serversErr, loading: serversLoading, reload: reloadServers } = useAsync(() => api.myServers(), []);
|
||||||
|
const { data: linkData, error: linkErr, loading: linkLoading } = useAsync(() => api.linkStatus(), []);
|
||||||
|
const { data: identity, error: meErr, loading: meLoading } = useAsync(() => api.me(), []);
|
||||||
|
|
||||||
|
// 仅对管理员加载镜像白名单列表,防止普通用户请求 403 报错
|
||||||
|
const { data: imagesData } = useAsync(() => {
|
||||||
|
if (identity?.is_admin) {
|
||||||
|
return api.listImages();
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}, [identity]);
|
||||||
|
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const servers = data ?? [];
|
const list = servers ?? [];
|
||||||
const by = (p: Phase) => servers.filter((s) => s.phase === p).length;
|
const by = (p: Phase) => list.filter((s) => s.phase === p).length;
|
||||||
return {
|
return {
|
||||||
total: servers.length,
|
total: list.length,
|
||||||
running: by("Running"),
|
running: by("Running"),
|
||||||
players: servers.reduce((n, s) => n + (s.players ?? 0), 0),
|
players: list.reduce((n, s) => n + (s.players ?? 0), 0),
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [servers]);
|
||||||
|
|
||||||
|
const loading = serversLoading || linkLoading || meLoading;
|
||||||
|
const error = serversErr || linkErr || meErr;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
@@ -61,12 +87,20 @@ export function Dashboard() {
|
|||||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !data ? (
|
{loading && !servers ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorState error={error} onRetry={reload} />
|
<ErrorState error={error} onRetry={reloadServers} />
|
||||||
|
) : !cfg ? (
|
||||||
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
<FleetView servers={data ?? []} counts={counts} hasConfig={!!cfg} />
|
<FleetView
|
||||||
|
servers={servers ?? []}
|
||||||
|
counts={counts}
|
||||||
|
linkStatus={linkData || undefined}
|
||||||
|
images={imagesData || []}
|
||||||
|
isAdmin={!!identity?.is_admin}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -75,55 +109,405 @@ export function Dashboard() {
|
|||||||
function FleetView({
|
function FleetView({
|
||||||
servers,
|
servers,
|
||||||
counts,
|
counts,
|
||||||
hasConfig,
|
linkStatus,
|
||||||
|
images,
|
||||||
|
isAdmin,
|
||||||
}: {
|
}: {
|
||||||
servers: ServerInfo[];
|
servers: ServerInfo[];
|
||||||
counts: { total: number; running: number; players: number };
|
counts: { total: number; running: number; players: number };
|
||||||
hasConfig: boolean;
|
linkStatus?: { linked: boolean };
|
||||||
|
images: WhitelistImage[];
|
||||||
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
if (servers.length === 0) {
|
|
||||||
|
// 1. 统计各状态 of 服务器数量
|
||||||
|
const statusStats = useMemo(() => {
|
||||||
|
let running = 0;
|
||||||
|
let starting = 0;
|
||||||
|
let stopping = 0;
|
||||||
|
let stopped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let unknown = 0;
|
||||||
|
let maxPlayers = 0;
|
||||||
|
|
||||||
|
servers.forEach((s) => {
|
||||||
|
if (s.phase === "Running") running++;
|
||||||
|
else if (s.phase === "Starting") starting++;
|
||||||
|
else if (s.phase === "Stopping") stopping++;
|
||||||
|
else if (s.phase === "Stopped") stopped++;
|
||||||
|
else if (s.phase === "Failed") failed++;
|
||||||
|
else unknown++;
|
||||||
|
|
||||||
|
maxPlayers += s.maxPlayers ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
starting,
|
||||||
|
stopping,
|
||||||
|
stopped,
|
||||||
|
failed,
|
||||||
|
unknown,
|
||||||
|
maxPlayers,
|
||||||
|
};
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
|
const loadPercentage = statusStats.maxPlayers > 0
|
||||||
|
? Math.round((counts.players / statusStats.maxPlayers) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 2. 自建与外部镜像真实统计
|
||||||
|
const builtImagesCount = useMemo(() =>
|
||||||
|
images.filter((img) => img.source === "built" || !img.source).length,
|
||||||
|
[images]
|
||||||
|
);
|
||||||
|
const externalImagesCount = useMemo(() =>
|
||||||
|
images.filter((img) => img.source === "external").length,
|
||||||
|
[images]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 我拥有及可认领服务器真实统计
|
||||||
|
const ownedServersCount = useMemo(() =>
|
||||||
|
servers.filter((s) => s.owned).length,
|
||||||
|
[servers]
|
||||||
|
);
|
||||||
|
const claimableServersCount = useMemo(() =>
|
||||||
|
servers.filter((s) => s.claimable).length,
|
||||||
|
[servers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 各种真实状态比率统计
|
||||||
|
const runningRatio = useMemo(() =>
|
||||||
|
servers.length > 0 ? Math.round((statusStats.running / servers.length) * 100) : 0,
|
||||||
|
[servers, statusStats.running]
|
||||||
|
);
|
||||||
|
const stoppedRatio = useMemo(() =>
|
||||||
|
servers.length > 0 ? Math.round((statusStats.stopped / servers.length) * 100) : 0,
|
||||||
|
[servers, statusStats.stopped]
|
||||||
|
);
|
||||||
|
const failedRatio = useMemo(() =>
|
||||||
|
servers.length > 0 ? Math.round((statusStats.failed / servers.length) * 100) : 0,
|
||||||
|
[servers, statusStats.failed]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 辅助渲染百分比进度条段
|
||||||
|
const renderBarSegment = (count: number, colorClass: string) => {
|
||||||
|
if (servers.length === 0 || count === 0) return null;
|
||||||
|
const widthPct = (count / servers.length) * 100;
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<div
|
||||||
title={t("no_servers_title")}
|
className={`${colorClass} h-full transition-all`}
|
||||||
hint={t("no_servers_hint")}
|
style={{ width: `${widthPct}%` }}
|
||||||
>
|
/>
|
||||||
<Link to="/servers">
|
|
||||||
<Button>{t("go_to_my_servers")}</Button>
|
|
||||||
</Link>
|
|
||||||
</EmptyState>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
|
{/* 顶部 Stat */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<Stat icon={Server} label={t("stat_servers")} value={counts.total} />
|
<Stat icon={Server} label={t("stat_servers")} value={counts.total} />
|
||||||
<Stat icon={Play} label={t("stat_running")} value={counts.running} />
|
<Stat icon={Play} label={t("stat_running")} value={counts.running} />
|
||||||
<Stat icon={Activity} label={t("stat_players_online")} value={counts.players} />
|
<Stat icon={Activity} label={t("stat_players_online")} value={counts.players} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 items-stretch">
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
{/* 左侧:账号状态、健康度及负载监控 (lg:col-span-8 h-full 捕获拉伸高度) */}
|
||||||
<CardTitle className="text-base">{t("fleet")}</CardTitle>
|
<div className="lg:col-span-8 flex flex-col gap-6 h-full">
|
||||||
<Link to="/servers">
|
{/* 1. 游戏角色绑定 Banner */}
|
||||||
<Button variant="ghost" size="sm">
|
<Card className="overflow-hidden">
|
||||||
{t("manage")} <Hand className="h-3.5 w-3.5" />
|
<CardContent className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
</Button>
|
{linkStatus?.linked ? (
|
||||||
</Link>
|
<>
|
||||||
</CardHeader>
|
<div className="space-y-1 max-w-xl">
|
||||||
<CardContent className="p-0">
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-semibold text-sm">
|
||||||
{hasConfig ? (
|
<CheckCircle className="h-5 w-5 shrink-0" />
|
||||||
<div className="h-[340px] w-full bg-gradient-to-b from-transparent to-primary/5">
|
<span>{t("account_linked_title")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{t("account_linked_desc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/account" className="shrink-0 w-full sm:w-auto">
|
||||||
|
<Button variant="outline" size="sm" className="w-full text-xs">
|
||||||
|
管理游戏角色
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1 max-w-xl">
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm">
|
||||||
|
<AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" />
|
||||||
|
<span>{t("account_unlinked_title")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{t("account_unlinked_desc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/account" className="shrink-0 w-full sm:w-auto">
|
||||||
|
<Button variant="default" size="sm" className="w-full text-xs">
|
||||||
|
{t("go_link")} <ArrowRight className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2. 管理员展示集群负载健康与镜像参数看板,普通用户展示极简引导 */}
|
||||||
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
|
{/* 集群负载与健康卡片 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3 pt-5">
|
||||||
|
<CardTitle className="text-base">{t("fleet_load")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pb-5">
|
||||||
|
{/* 玩家负载进度条 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Cpu className="h-3.5 w-3.5 text-primary" /> {t("active_load")}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{counts.players} / {statusStats.maxPlayers} {t("stat_players_online")} ({loadPercentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full rounded-full transition-all"
|
||||||
|
style={{ width: `${loadPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器状态分布健康条 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{t("status_distribution")}</span>
|
||||||
|
<span className="font-medium text-foreground">{servers.length} 实例</span>
|
||||||
|
</div>
|
||||||
|
{servers.length > 0 ? (
|
||||||
|
<div className="h-3 w-full bg-secondary rounded-full overflow-hidden flex">
|
||||||
|
{renderBarSegment(statusStats.running, "bg-[#22c55e]")}
|
||||||
|
{renderBarSegment(statusStats.starting, "bg-[#eab308]")}
|
||||||
|
{renderBarSegment(statusStats.stopping, "bg-[#f97316]")}
|
||||||
|
{renderBarSegment(statusStats.failed, "bg-[#ef4444]")}
|
||||||
|
{renderBarSegment(statusStats.stopped, "bg-[#64748b]")}
|
||||||
|
{renderBarSegment(statusStats.unknown, "bg-[#94a3b8]")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground/60 py-1 text-center bg-muted/20 rounded">
|
||||||
|
{t("no_servers_monitor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 镜像及规格说明 */}
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg border bg-accent/5 text-xs text-muted-foreground">
|
||||||
|
<ShieldCheck className="h-4.5 w-4.5 text-primary shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-foreground mr-1">{images.length}</span>
|
||||||
|
{t("images_count")}。所有后端镜像运行前均通过 Trivy CVE 漏洞扫描,强制在隔离的安全容器中托管(spec §16)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 平台环境与配置参数卡片 (flex-1 自动拉伸) */}
|
||||||
|
<Card className="flex-1 flex flex-col justify-between">
|
||||||
|
<CardHeader className="pb-3 pt-5 px-6">
|
||||||
|
<CardTitle className="text-base font-semibold text-foreground">
|
||||||
|
{t("system_specs")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6 pt-1 flex-1 flex flex-col justify-between">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-6">
|
||||||
|
{/* 镜像总数 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_images_total")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{images.length}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 集群内自建镜像 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Box className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_images_built")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{builtImagesCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 外部登记导入镜像 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Database className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_images_external")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{externalImagesCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可见服务器总数 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Server className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_servers_total")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{counts.total}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 我拥有管理的服务器 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_servers_owned")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{ownedServersCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 公网可认领服务器 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Hand className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_servers_claimable")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{claimableServersCount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 运行中实例占比 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Activity className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_running_ratio")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-[#22c55e] leading-none mt-1.5">{runningRatio}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 节点休眠率 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Activity className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_stopped_ratio")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-slate-500 leading-none mt-1.5">{stoppedRatio}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 运行故障率 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-medium">{t("spec_failed_ratio")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono text-rose-500 leading-none mt-1.5">{failedRatio}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card className="overflow-hidden flex flex-col justify-between flex-1">
|
||||||
|
<CardHeader className="pb-3 pt-5 px-6">
|
||||||
|
<CardTitle className="text-base font-semibold text-foreground">
|
||||||
|
{t("welcome_title")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6 pt-1 flex-1 flex flex-col justify-between gap-4">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{t("welcome_desc")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 极简平台新手引导指引(真实且有用) */}
|
||||||
|
<div className="p-3.5 rounded-lg border bg-accent/5 text-[11px] text-muted-foreground space-y-1.5">
|
||||||
|
<div className="font-semibold text-foreground text-xs">快速入门指引:</div>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>认领无主服后,系统会自动将您的 Minecraft UUID 添加至准入白名单。</li>
|
||||||
|
<li>服务器如果长时间处于无人在线空闲状态,平台会自动执行休眠存档以节约资源。</li>
|
||||||
|
<li>游戏入口统一由 Velocity 网关承载,只需连接您的专用子域名即可。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Link to="/servers">
|
||||||
|
<Button variant="default" size="sm" className="text-xs gap-1.5 w-full sm:w-auto">
|
||||||
|
{t("go_to_servers_btn")} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:3D星图拓扑监控 (lg:col-span-4 h-full) */}
|
||||||
|
<Card className="lg:col-span-4 flex flex-col overflow-hidden h-full">
|
||||||
|
<CardHeader className="space-y-1 pb-4">
|
||||||
|
<CardTitle className="text-base">{t("fleet")}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">三维体素网络拓扑图</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col p-0 justify-between">
|
||||||
|
<div className="flex-1 min-h-[220px] w-full bg-gradient-to-b from-transparent to-primary/5 border-b relative">
|
||||||
<Suspense fallback={<Loading label={t("loading_scene")} />}>
|
<Suspense fallback={<Loading label={t("loading_scene")} />}>
|
||||||
<VoxelFleet servers={servers} />
|
<VoxelFleet servers={servers} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{/* 状态图例说明 + 指标统计 */}
|
||||||
<Loading label={t("preparing_scene")} />
|
<div className="p-4 space-y-3 text-xs bg-muted/10">
|
||||||
)}
|
<div className="font-semibold text-muted-foreground text-[10px] uppercase tracking-wider">
|
||||||
</CardContent>
|
{t("legend_title")}
|
||||||
</Card>
|
</div>
|
||||||
</>
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#22c55e]" />
|
||||||
|
<span className="text-muted-foreground text-[11px]">{t("legend_running")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-foreground font-semibold">{statusStats.running}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#eab308] animate-pulse" />
|
||||||
|
<span className="text-muted-foreground text-[11px]">{t("legend_starting")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-foreground font-semibold">{statusStats.starting}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#f97316] animate-pulse" />
|
||||||
|
<span className="text-muted-foreground text-[11px]">{t("legend_stopping")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-foreground font-semibold">{statusStats.stopping}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#64748b]" />
|
||||||
|
<span className="text-muted-foreground text-[11px]">{t("legend_stopped")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-foreground font-semibold">{statusStats.stopped}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#ef4444]" />
|
||||||
|
<span className="text-muted-foreground text-[11px]">{t("legend_failed")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-foreground font-semibold">{statusStats.failed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#94a3b8]" />
|
||||||
|
<span className="text-muted-foreground text-[11px]">{t("legend_unknown")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] text-foreground font-semibold">{statusStats.unknown}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user