fix(panel): 加错误边界与新版本 chunk 自动刷新,无 WebGL 时仪表盘降级为平面视图

This commit is contained in:
Lemon-miaow committed 2026-09-24 15:27:29 +08:00
1 parent 248fa5d100
commit 18e2397272
13 files changed
+432 -14

No files matched your search

+8 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select";
@@ -9,6 +9,7 @@ import { visibleSections, type NavSection } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { useTheme } from "@/lib/theme";
import { api } from "@/lib/api";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
const { t } = useTranslation("navigation");
@@ -138,6 +139,7 @@ function ThemeToggle() {
export function AppShell() {
const { isAdmin, isOwner } = useTier();
const { pathname } = useLocation();
const { t, i18n } = useTranslation("navigation");
// Sections are derived purely from is_admin and is_owner: User-Side always,
// Admin-Side only for admins, Owner-Side only for the platform owner. Both
@@ -200,7 +202,11 @@ export function AppShell() {
</header>
<main className="flex flex-1 flex-col p-6">
<div className="mx-auto flex w-full max-w-8xl flex-1 flex-col gap-6">
<Outlet />
{/* A crash on one page leaves the navigation usable; moving to
another route clears it. */}
<ErrorBoundary resetKey={pathname}>
<Outlet />
</ErrorBoundary>
</div>
</main>
</div>
+147
View File
@@ -0,0 +1,147 @@
import { Component, useState, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, Check, Copy, Home, RefreshCw, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { isChunkLoadError, reloadForNewDeploy } from "@/lib/chunk";
import { cn } from "@/lib/utils";
// ErrorBoundary keeps one broken render from unmounting the whole panel. The
// root boundary (main.tsx) covers the providers and the shell; the one around
// AppShell's Outlet keeps the navigation alive and resets on route change, so a
// crash on one page never strands the user there.
interface Props {
children: ReactNode;
/** Changing it clears a caught error (the Outlet passes the pathname). */
resetKey?: unknown;
/** Replaces the crash page, e.g. a flat fleet grid for the 3D card. */
fallback?: (error: unknown, reset: () => void) => ReactNode;
/** "screen" fills the viewport (root); "page" sits inside the shell. */
variant?: "screen" | "page";
}
interface State {
failed: boolean;
error: unknown;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { failed: false, error: null };
static getDerivedStateFromError(error: unknown): State {
return { failed: true, error };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error("felis panel: render failed", error, info.componentStack);
if (isChunkLoadError(error)) reloadForNewDeploy();
}
componentDidUpdate(prev: Props) {
if (this.state.failed && !Object.is(prev.resetKey, this.props.resetKey)) {
this.setState({ failed: false, error: null });
}
}
reset = () => this.setState({ failed: false, error: null });
render() {
if (!this.state.failed) return this.props.children;
if (this.props.fallback) return this.props.fallback(this.state.error, this.reset);
return <CrashPage error={this.state.error} onRetry={this.reset} screen={this.props.variant === "screen"} />;
}
}
function errorText(error: unknown): string {
if (error instanceof Error) return `${error.name}: ${error.message}`;
try {
return typeof error === "string" ? error : JSON.stringify(error);
} catch {
return String(error);
}
}
function CrashPage({ error, onRetry, screen }: { error: unknown; onRetry: () => void; screen: boolean }) {
const { t } = useTranslation("common");
const [copied, setCopied] = useState(false);
const updated = isChunkLoadError(error);
const detail = errorText(error);
async function copy() {
try {
await navigator.clipboard.writeText(`${detail}\n${window.location.href}`);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard denied: the text stays selectable in the details block.
}
}
return (
<div
role="alert"
className={cn(
"flex items-center justify-center px-4",
screen ? "min-h-screen bg-background text-foreground" : "flex-1 py-16",
)}
>
<div className="w-full max-w-md space-y-5 text-center">
<div
className={cn(
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
updated ? "bg-primary/10 text-primary" : "bg-destructive/10 text-destructive",
)}
>
{updated ? <Sparkles className="h-6 w-6" /> : <AlertTriangle className="h-6 w-6" />}
</div>
<div className="space-y-2">
<h1 className="text-lg font-semibold">{updated ? t("crash_updated_title") : t("crash_title")}</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
{updated ? t("crash_updated_body") : t("crash_body")}
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button onClick={() => window.location.reload()} className="gap-1.5">
<RefreshCw className="h-4 w-4" />
{t("crash_reload")}
</Button>
{!updated && (
<Button variant="outline" onClick={onRetry}>
{t("try_again")}
</Button>
)}
{/* A plain anchor: the root boundary renders outside the router. */}
<Button variant="ghost" asChild className="gap-1.5">
<a href="/">
<Home className="h-4 w-4" />
{t("back_to_dashboard")}
</a>
</Button>
</div>
{!updated && (
<details className="group rounded-md border border-border/60 bg-muted/30 text-left text-xs">
<summary className="cursor-pointer select-none px-3 py-2 text-muted-foreground hover:text-foreground">
{t("crash_details")}
</summary>
<div className="flex items-start gap-2 border-t border-border/60 px-3 py-2">
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all font-mono text-[11px] text-foreground">
{detail}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={copy}
aria-label={t("crash_copy")}
title={t("crash_copy")}
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
</details>
)}
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Link } from "react-router-dom";
import { Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PHASE_KEY, phaseColor } from "@/components/PhaseBadge";
import { cn } from "@/lib/utils";
import type { Phase, ServerInfo } from "@/lib/types";
// FleetGrid is the flat stand-in for VoxelFleet when WebGL is unavailable or the
// 3D chunk failed: the same square layout and phase palette seen from above, so
// the legend under the card still reads correctly. It lives outside the three.js
// chunk so it renders even when that chunk cannot load.
const TRANSIENT: ReadonlySet<Phase> = new Set<Phase>(["Starting", "Stopping"]);
export type FleetGridReason = "webgl" | "error";
export function FleetGrid({
servers,
reason,
}: {
servers: Pick<ServerInfo, "name" | "displayName" | "phase">[];
reason: FleetGridReason;
}) {
const { t } = useTranslation(["dashboard", "servers"]);
const cols = Math.max(1, Math.ceil(Math.sqrt(servers.length)));
const n = servers.length;
const tile = n <= 9 ? "3rem" : n <= 36 ? "1.75rem" : n <= 100 ? "1.25rem" : "0.875rem";
return (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 overflow-auto p-4">
{servers.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("dashboard:fleet_flat_empty")}</p>
) : (
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${cols}, ${tile})`, gridAutoRows: tile }}
>
{servers.map((s) => {
const color = phaseColor(s.phase);
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[s.phase] ?? PHASE_KEY.Unknown)}`;
return (
<Link
key={s.name}
to={`/servers/${s.name}`}
title={label}
aria-label={label}
className={cn(
"rounded-md ring-1 ring-inset ring-white/10 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
TRANSIENT.has(s.phase) && "animate-pulse",
)}
style={{
backgroundColor: color,
backgroundImage: "linear-gradient(145deg, rgb(255 255 255 / 0.28), rgb(0 0 0 / 0.18))",
boxShadow: s.phase === "Running" ? `0 0 14px -2px ${color}` : undefined,
}}
/>
);
})}
</div>
)}
<p className="flex max-w-xs items-center gap-1.5 text-center text-[11px] leading-snug text-muted-foreground">
<Info className="h-3.5 w-3.5 shrink-0" />
{reason === "webgl" ? t("dashboard:fleet_flat_webgl") : t("dashboard:fleet_flat_error")}
</p>
</div>
);
}
+23 -2
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { phaseColor } from "@/components/PhaseBadge";
import { FleetGrid } from "@/components/FleetGrid";
import { webglAvailable } from "@/lib/webgl";
import type { ServerInfo } from "@/lib/types";
// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by
@@ -10,6 +12,8 @@ import type { ServerInfo } from "@/lib/types";
// • the render loop is paused while the tab is hidden (no background GPU burn);
// • everything is disposed on unmount (no context leak on route changes).
// Raw three.js (not react-three-fiber) keeps the dependency/build surface minimal.
// Without WebGL (acceleration off, some VMs and remote desktops) the renderer
// cannot be created at all, so the card falls back to the flat FleetGrid.
interface Props {
servers: ServerInfo[];
@@ -18,7 +22,15 @@ interface Props {
const STARTING = "Starting";
export function VoxelFleet({ servers }: Props) {
const [flat, setFlat] = useState(() => !webglAvailable());
if (flat) return <FleetGrid servers={servers} reason="webgl" />;
return <VoxelScene servers={servers} onUnsupported={() => setFlat(true)} />;
}
function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => void }) {
const mountRef = useRef<HTMLDivElement>(null);
const onUnsupportedRef = useRef(onUnsupported);
onUnsupportedRef.current = onUnsupported;
// Latest servers without re-running the heavy setup effect on every poll.
const serversRef = useRef(servers);
serversRef.current = servers;
@@ -32,7 +44,16 @@ export function VoxelFleet({ servers }: Props) {
camera.position.set(6, 5.5, 9);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// The probe can pass while the real context still fails (blocklisted GPU,
// context limit reached); three throws then, and the card goes flat.
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
} catch (err) {
console.warn("felis panel: WebGL renderer unavailable, showing the flat fleet", err);
onUnsupportedRef.current();
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.domElement.style.position = "absolute";
renderer.domElement.style.top = "0";