Unverified Commit 18e23972 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 248fa5d1
Loading
Loading
Loading
Loading
+7 −1
Changes for panel/src/components/AppShell.tsx: 7 added lines, 1 removed line.
Original line number Diff line number Diff line
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">
            {/* A crash on one page leaves the navigation usable; moving to
                another route clears it. */}
            <ErrorBoundary resetKey={pathname}>
              <Outlet />
            </ErrorBoundary>
          </div>
        </main>
      </div>
+147 −0
Changes for panel/src/components/ErrorBoundary.tsx: 147 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for panel/src/components/FleetGrid.tsx: 67 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
Changes for panel/src/components/VoxelFleet.tsx: 23 added lines, 2 removed lines.
Original line number Diff line number Diff line
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";
+8 −1
Changes for panel/src/i18n/resources/en-US/common.json: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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"
}
Loading