fix(panel): 加错误边界与新版本 chunk 自动刷新,无 WebGL 时仪表盘降级为平面视图
This commit is contained in:
13 files changed
+427
-9
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { Cat, Globe, Sun, Moon, LogOut } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
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 { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
||||||
const { t } = useTranslation("navigation");
|
const { t } = useTranslation("navigation");
|
||||||
@@ -138,6 +139,7 @@ function ThemeToggle() {
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { isAdmin, isOwner } = useTier();
|
const { isAdmin, isOwner } = useTier();
|
||||||
|
const { pathname } = useLocation();
|
||||||
const { t, i18n } = useTranslation("navigation");
|
const { t, i18n } = useTranslation("navigation");
|
||||||
// Sections are derived purely from is_admin and is_owner: User-Side always,
|
// 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
|
// Admin-Side only for admins, Owner-Side only for the platform owner. Both
|
||||||
@@ -200,7 +202,11 @@ export function AppShell() {
|
|||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col p-6">
|
<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">
|
<div className="mx-auto flex w-full max-w-8xl flex-1 flex-col gap-6">
|
||||||
|
{/* A crash on one page leaves the navigation usable; moving to
|
||||||
|
another route clears it. */}
|
||||||
|
<ErrorBoundary resetKey={pathname}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { phaseColor } from "@/components/PhaseBadge";
|
import { phaseColor } from "@/components/PhaseBadge";
|
||||||
|
import { FleetGrid } from "@/components/FleetGrid";
|
||||||
|
import { webglAvailable } from "@/lib/webgl";
|
||||||
import type { ServerInfo } from "@/lib/types";
|
import type { ServerInfo } from "@/lib/types";
|
||||||
|
|
||||||
// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by
|
// 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);
|
// • 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).
|
// • everything is disposed on unmount (no context leak on route changes).
|
||||||
// Raw three.js (not react-three-fiber) keeps the dependency/build surface minimal.
|
// 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 {
|
interface Props {
|
||||||
servers: ServerInfo[];
|
servers: ServerInfo[];
|
||||||
@@ -18,7 +22,15 @@ interface Props {
|
|||||||
const STARTING = "Starting";
|
const STARTING = "Starting";
|
||||||
|
|
||||||
export function VoxelFleet({ servers }: Props) {
|
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 mountRef = useRef<HTMLDivElement>(null);
|
||||||
|
const onUnsupportedRef = useRef(onUnsupported);
|
||||||
|
onUnsupportedRef.current = onUnsupported;
|
||||||
// Latest servers without re-running the heavy setup effect on every poll.
|
// Latest servers without re-running the heavy setup effect on every poll.
|
||||||
const serversRef = useRef(servers);
|
const serversRef = useRef(servers);
|
||||||
serversRef.current = servers;
|
serversRef.current = servers;
|
||||||
@@ -32,7 +44,16 @@ export function VoxelFleet({ servers }: Props) {
|
|||||||
camera.position.set(6, 5.5, 9);
|
camera.position.set(6, 5.5, 9);
|
||||||
camera.lookAt(0, 0, 0);
|
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.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
renderer.domElement.style.position = "absolute";
|
renderer.domElement.style.position = "absolute";
|
||||||
renderer.domElement.style.top = "0";
|
renderer.domElement.style.top = "0";
|
||||||
|
|||||||
@@ -19,5 +19,12 @@
|
|||||||
"toggle_theme": "Toggle theme",
|
"toggle_theme": "Toggle theme",
|
||||||
"pagination_prev": "Previous",
|
"pagination_prev": "Previous",
|
||||||
"pagination_next": "Next",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -44,5 +44,8 @@
|
|||||||
"legend_unknown": "Unknown",
|
"legend_unknown": "Unknown",
|
||||||
"welcome_title": "Welcome to Felis Lolicon",
|
"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.",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -19,5 +19,12 @@
|
|||||||
"toggle_theme": "切换主题",
|
"toggle_theme": "切换主题",
|
||||||
"pagination_prev": "上一页",
|
"pagination_prev": "上一页",
|
||||||
"pagination_next": "下一页",
|
"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": "复制错误信息"
|
||||||
}
|
}
|
||||||
@@ -44,5 +44,8 @@
|
|||||||
"legend_unknown": "未知状态",
|
"legend_unknown": "未知状态",
|
||||||
"welcome_title": "欢迎使用 Felis 编排平台",
|
"welcome_title": "欢迎使用 Felis 编排平台",
|
||||||
"welcome_desc": "您已成功登录用户控制台。您可以在「我的服务器」页面查看并认领分配给您的专属节点,或者对其进行休眠、启动和控制台日志监控。",
|
"welcome_desc": "您已成功登录用户控制台。您可以在「我的服务器」页面查看并认领分配给您的专属节点,或者对其进行休眠、启动和控制台日志监控。",
|
||||||
"go_to_servers_btn": "操作我的服务器"
|
"go_to_servers_btn": "操作我的服务器",
|
||||||
|
"fleet_flat_webgl": "浏览器未启用 WebGL,已切换为平面视图。",
|
||||||
|
"fleet_flat_error": "3D 视图加载失败,已切换为平面视图。",
|
||||||
|
"fleet_flat_empty": "还没有服务器"
|
||||||
}
|
}
|
||||||
@@ -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<Storage, "getItem" | "setItem"> & { data: Map<string, string> } {
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Storage, "getItem" | "setItem">;
|
||||||
|
|
||||||
|
/** 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<T extends ComponentType<any>>(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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,10 +2,16 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import { installPreloadReload } from "./lib/chunk";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
installPreloadReload();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary variant="screen">
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -20,13 +20,19 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Loading, ErrorState } from "@/components/States";
|
import { Loading, ErrorState } from "@/components/States";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
import { FleetGrid } from "@/components/FleetGrid";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useAsync, useConfig } from "@/lib/hooks";
|
import { useAsync, useConfig } from "@/lib/hooks";
|
||||||
|
import { lazyWithReload } from "@/lib/chunk";
|
||||||
|
import { webglAvailable } from "@/lib/webgl";
|
||||||
import type { Phase, ServerInfo, WhitelistImage } 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.
|
||||||
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 })),
|
import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -426,9 +432,15 @@ function FleetView({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col p-0 justify-between">
|
<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">
|
<div className="flex-1 min-h-[220px] w-full bg-gradient-to-b from-transparent to-primary/5 border-b relative">
|
||||||
|
<ErrorBoundary fallback={() => <FleetGrid servers={servers} reason="error" />}>
|
||||||
|
{webglAvailable() ? (
|
||||||
<Suspense fallback={<Loading label={t("loading_scene")} />}>
|
<Suspense fallback={<Loading label={t("loading_scene")} />}>
|
||||||
<VoxelFleet servers={servers} />
|
<VoxelFleet servers={servers} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<FleetGrid servers={servers} reason="webgl" />
|
||||||
|
)}
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
{/* 状态图例说明 + 指标统计 */}
|
{/* 状态图例说明 + 指标统计 */}
|
||||||
<div className="p-4 space-y-3 text-xs bg-muted/10">
|
<div className="p-4 space-y-3 text-xs bg-muted/10">
|
||||||
|
|||||||
Reference in new issue
Block a user