From 18e23972728dee2c4953100bc6a35d6ee44b35c8 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 15:27:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E5=8A=A0=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=96=B0=E7=89=88=E6=9C=AC=20chunk?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=EF=BC=8C=E6=97=A0=20Web?= =?UTF-8?q?GL=20=E6=97=B6=E4=BB=AA=E8=A1=A8=E7=9B=98=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E5=B9=B3=E9=9D=A2=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/src/components/AppShell.tsx | 10 +- panel/src/components/ErrorBoundary.tsx | 147 ++++++++++++++++++ panel/src/components/FleetGrid.tsx | 67 ++++++++ panel/src/components/VoxelFleet.tsx | 25 ++- panel/src/i18n/resources/en-US/common.json | 9 +- panel/src/i18n/resources/en-US/dashboard.json | 5 +- panel/src/i18n/resources/zh-CN/common.json | 9 +- panel/src/i18n/resources/zh-CN/dashboard.json | 5 +- panel/src/lib/chunk.test.ts | 55 +++++++ panel/src/lib/chunk.ts | 66 ++++++++ panel/src/lib/webgl.ts | 18 +++ panel/src/main.tsx | 8 +- panel/src/pages/Dashboard.tsx | 22 ++- 13 files changed, 432 insertions(+), 14 deletions(-) create mode 100644 panel/src/components/ErrorBoundary.tsx create mode 100644 panel/src/components/FleetGrid.tsx create mode 100644 panel/src/lib/chunk.test.ts create mode 100644 panel/src/lib/chunk.ts create mode 100644 panel/src/lib/webgl.ts diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index f50fff7..73f3bd9 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -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() {
- + {/* A crash on one page leaves the navigation usable; moving to + another route clears it. */} + + +
diff --git a/panel/src/components/ErrorBoundary.tsx b/panel/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..07a120e --- /dev/null +++ b/panel/src/components/ErrorBoundary.tsx @@ -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 { + 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 ; + } +} + +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 ( +
+
+
+ {updated ? : } +
+
+

{updated ? t("crash_updated_title") : t("crash_title")}

+

+ {updated ? t("crash_updated_body") : t("crash_body")} +

+
+
+ + {!updated && ( + + )} + {/* A plain anchor: the root boundary renders outside the router. */} + +
+ {!updated && ( +
+ + {t("crash_details")} + +
+ + {detail} + + +
+
+ )} +
+
+ ); +} diff --git a/panel/src/components/FleetGrid.tsx b/panel/src/components/FleetGrid.tsx new file mode 100644 index 0000000..7db8559 --- /dev/null +++ b/panel/src/components/FleetGrid.tsx @@ -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 = new Set(["Starting", "Stopping"]); + +export type FleetGridReason = "webgl" | "error"; + +export function FleetGrid({ + servers, + reason, +}: { + servers: Pick[]; + 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 ( +
+ {servers.length === 0 ? ( +

{t("dashboard:fleet_flat_empty")}

+ ) : ( +
+ {servers.map((s) => { + const color = phaseColor(s.phase); + const label = `${s.displayName || s.name} · ${t(PHASE_KEY[s.phase] ?? PHASE_KEY.Unknown)}`; + return ( + + ); + })} +
+ )} +

+ + {reason === "webgl" ? t("dashboard:fleet_flat_webgl") : t("dashboard:fleet_flat_error")} +

+
+ ); +} diff --git a/panel/src/components/VoxelFleet.tsx b/panel/src/components/VoxelFleet.tsx index b1b6eed..5051011 100644 --- a/panel/src/components/VoxelFleet.tsx +++ b/panel/src/components/VoxelFleet.tsx @@ -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 ; + return setFlat(true)} />; +} + +function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => void }) { const mountRef = useRef(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"; diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json index 329a2c1..d29fd54 100644 --- a/panel/src/i18n/resources/en-US/common.json +++ b/panel/src/i18n/resources/en-US/common.json @@ -19,5 +19,12 @@ "toggle_theme": "Toggle theme", "pagination_prev": "Previous", "pagination_next": "Next", - "pagination_of": "Page {{current}} of {{total}}" + "pagination_of": "Page {{current}} of {{total}}", + "crash_title": "This page hit an error", + "crash_body": "The panel ran into a problem rendering this page. Reloading usually fixes it; if it keeps happening, send the error details below to your admin.", + "crash_updated_title": "The panel was updated", + "crash_updated_body": "A new version of the panel was just released. Reload to open this page.", + "crash_reload": "Reload", + "crash_details": "Error details", + "crash_copy": "Copy error details" } diff --git a/panel/src/i18n/resources/en-US/dashboard.json b/panel/src/i18n/resources/en-US/dashboard.json index 258c15a..a8f26ef 100644 --- a/panel/src/i18n/resources/en-US/dashboard.json +++ b/panel/src/i18n/resources/en-US/dashboard.json @@ -44,5 +44,8 @@ "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" + "go_to_servers_btn": "Manage My Servers", + "fleet_flat_webgl": "WebGL is unavailable in this browser, so the fleet is shown flat.", + "fleet_flat_error": "The 3D view failed to load, so the fleet is shown flat.", + "fleet_flat_empty": "No servers yet" } diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json index e20d302..eaaa210 100644 --- a/panel/src/i18n/resources/zh-CN/common.json +++ b/panel/src/i18n/resources/zh-CN/common.json @@ -19,5 +19,12 @@ "toggle_theme": "切换主题", "pagination_prev": "上一页", "pagination_next": "下一页", - "pagination_of": "第 {{current}} / {{total}} 页" + "pagination_of": "第 {{current}} / {{total}} 页", + "crash_title": "这个页面出错了", + "crash_body": "面板渲染这个页面时遇到了问题。重新加载通常就能恢复;如果反复出现,请把下面的错误信息发给管理员。", + "crash_updated_title": "面板已更新", + "crash_updated_body": "面板刚发布了新版本,这个页面需要重新加载才能打开。", + "crash_reload": "重新加载", + "crash_details": "错误信息", + "crash_copy": "复制错误信息" } diff --git a/panel/src/i18n/resources/zh-CN/dashboard.json b/panel/src/i18n/resources/zh-CN/dashboard.json index e3ee613..758e50a 100644 --- a/panel/src/i18n/resources/zh-CN/dashboard.json +++ b/panel/src/i18n/resources/zh-CN/dashboard.json @@ -44,5 +44,8 @@ "legend_unknown": "未知状态", "welcome_title": "欢迎使用 Felis 编排平台", "welcome_desc": "您已成功登录用户控制台。您可以在「我的服务器」页面查看并认领分配给您的专属节点,或者对其进行休眠、启动和控制台日志监控。", - "go_to_servers_btn": "操作我的服务器" + "go_to_servers_btn": "操作我的服务器", + "fleet_flat_webgl": "浏览器未启用 WebGL,已切换为平面视图。", + "fleet_flat_error": "3D 视图加载失败,已切换为平面视图。", + "fleet_flat_empty": "还没有服务器" } diff --git a/panel/src/lib/chunk.test.ts b/panel/src/lib/chunk.test.ts new file mode 100644 index 0000000..1bb7b84 --- /dev/null +++ b/panel/src/lib/chunk.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { claimChunkReload, isChunkLoadError } from "./chunk"; + +// After a deploy an old tab's lazy import 404s. The panel reloads once to pick up +// the new chunks; these cases pin which errors count and that a second failure +// inside the window surfaces instead of looping. + +function memory(): Pick & { data: Map } { + const data = new Map(); + return { data, getItem: (k) => data.get(k) ?? null, setItem: (k, v) => void data.set(k, v) }; +} + +describe("isChunkLoadError", () => { + it("recognizes each browser's wording", () => { + for (const msg of [ + "Failed to fetch dynamically imported module: https://x/assets/VoxelFleet-abc.js", + "error loading dynamically imported module", + "Importing a module script failed.", + "Unable to preload CSS for /assets/index-abc.css", + ]) { + expect(isChunkLoadError(new TypeError(msg))).toBe(true); + } + }); + + it("leaves ordinary render errors alone", () => { + expect(isChunkLoadError(new TypeError("Cannot read properties of undefined (reading 'name')"))).toBe(false); + expect(isChunkLoadError(null)).toBe(false); + }); +}); + +describe("claimChunkReload", () => { + it("allows the first reload and stamps it", () => { + const s = memory(); + expect(claimChunkReload(s, 1_000_000)).toBe(true); + expect(s.data.size).toBe(1); + }); + + it("refuses a second reload inside the window", () => { + const s = memory(); + claimChunkReload(s, 1_000_000); + expect(claimChunkReload(s, 1_010_000)).toBe(false); + }); + + it("allows another reload once the window has passed", () => { + const s = memory(); + claimChunkReload(s, 1_000_000); + expect(claimChunkReload(s, 1_031_000)).toBe(true); + }); + + it("declines without storage, since nothing could stop a loop", () => { + expect(claimChunkReload(null, 1_000_000)).toBe(false); + const broken = { getItem: () => { throw new Error("denied"); }, setItem: () => {} }; + expect(claimChunkReload(broken, 1_000_000)).toBe(false); + }); +}); diff --git a/panel/src/lib/chunk.ts b/panel/src/lib/chunk.ts new file mode 100644 index 0000000..d801442 --- /dev/null +++ b/panel/src/lib/chunk.ts @@ -0,0 +1,66 @@ +import { lazy, type ComponentType } from "react"; + +// A deploy replaces the hashed chunks, so a tab opened before it asks for files +// that no longer exist (panel.go answers index.html and the dynamic import +// fails). One full reload picks up the new index and its chunk names; the +// sessionStorage stamp keeps a real outage from turning into a reload loop. + +const RELOAD_KEY = "felis:chunk-reload-at"; +const RELOAD_WINDOW_MS = 30_000; + +const CHUNK_ERROR = + /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|Unable to preload CSS|ChunkLoadError/i; + +/** isChunkLoadError recognizes a failed lazy import in Chrome, Firefox and Safari. */ +export function isChunkLoadError(err: unknown): boolean { + const text = err instanceof Error ? `${err.name} ${err.message}` : String(err ?? ""); + return CHUNK_ERROR.test(text); +} + +type Stamp = Pick; + +/** claimChunkReload reports whether this tab may reload now and, if so, stamps + * the attempt. Without usable storage there is no loop guard, so it declines. */ +export function claimChunkReload(storage: Stamp | null, now: number): boolean { + if (!storage) return false; + try { + const last = Number(storage.getItem(RELOAD_KEY) ?? 0); + if (Number.isFinite(last) && now - last < RELOAD_WINDOW_MS) return false; + storage.setItem(RELOAD_KEY, String(now)); + return true; + } catch { + return false; + } +} + +/** reloadForNewDeploy reloads the page once per window; false means the caller + * must surface the error instead. */ +export function reloadForNewDeploy(): boolean { + let storage: Stamp | null = null; + try { + storage = window.sessionStorage; + } catch { + // Storage blocked (privacy mode): no guard, no automatic reload. + } + if (!claimChunkReload(storage, Date.now())) return false; + window.location.reload(); + return true; +} + +/** lazyWithReload is React.lazy that reloads once when the chunk has gone. */ +export function lazyWithReload>(factory: () => Promise<{ default: T }>) { + return lazy(() => + factory().catch((err: unknown) => { + if (isChunkLoadError(err) && reloadForNewDeploy()) return new Promise<{ default: T }>(() => {}); + throw err; + }), + ); +} + +/** installPreloadReload covers Vite's own modulepreload/CSS preloads, which + * fail through the vite:preloadError event instead of the import promise. */ +export function installPreloadReload(): void { + window.addEventListener("vite:preloadError", (event) => { + if (reloadForNewDeploy()) event.preventDefault(); + }); +} diff --git a/panel/src/lib/webgl.ts b/panel/src/lib/webgl.ts new file mode 100644 index 0000000..5c1339e --- /dev/null +++ b/panel/src/lib/webgl.ts @@ -0,0 +1,18 @@ +// webglAvailable probes once whether this browser can create a WebGL context +// (hardware acceleration off, some VMs and remote desktops cannot). The probe +// context is released right away so the fleet view still gets its own. + +let cached: boolean | undefined; + +export function webglAvailable(): boolean { + if (cached !== undefined) return cached; + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + gl?.getExtension("WEBGL_lose_context")?.loseContext(); + cached = gl != null; + } catch { + cached = false; + } + return cached; +} diff --git a/panel/src/main.tsx b/panel/src/main.tsx index 25d005a..5b6aeb8 100644 --- a/panel/src/main.tsx +++ b/panel/src/main.tsx @@ -2,10 +2,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./i18n"; import App from "./App"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { installPreloadReload } from "./lib/chunk"; import "./index.css"; +installPreloadReload(); + ReactDOM.createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/panel/src/pages/Dashboard.tsx b/panel/src/pages/Dashboard.tsx index d5cbdb6..96fa019 100644 --- a/panel/src/pages/Dashboard.tsx +++ b/panel/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useMemo } from "react"; +import { Suspense, useMemo } from "react"; import { Link } from "react-router-dom"; import { Activity, @@ -20,13 +20,19 @@ import { Button } from "@/components/ui/button"; import { Loading, ErrorState } from "@/components/States"; import { StatCard } from "@/components/StatCard"; import { PageHeader } from "@/components/PageHeader"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { FleetGrid } from "@/components/FleetGrid"; import { api } from "@/lib/api"; import { useAsync, useConfig } from "@/lib/hooks"; +import { lazyWithReload } from "@/lib/chunk"; +import { webglAvailable } from "@/lib/webgl"; import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types"; // 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. -const VoxelFleet = lazy(() => +// A failed chunk reloads the tab once (new deploy); after that the card falls +// back to the flat grid instead of taking the page down. +const VoxelFleet = lazyWithReload(() => import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })), ); @@ -426,9 +432,15 @@ function FleetView({
- }> - - + }> + {webglAvailable() ? ( + }> + + + ) : ( + + )} +
{/* 状态图例说明 + 指标统计 */}