From e9c854e78bc15c790a16ba368e4af037d502ba65 Mon Sep 17 00:00:00 2001 From: AbdullahM07 Date: Wed, 12 Aug 2026 02:23:01 +0300 Subject: [PATCH] fix(dashboard): stop a bad payload from making a server undeletable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server could only ever be deleted from one screen — the detail page's ⋯ menu — so anything that stopped that page rendering also made the server impossible to remove. Reported against an offline box, which is the worst case: the one you most want to delete is the one whose page does the most work. Two render throws found by mounting the page with a real DOM: - RateLimitSettings guarded its config with `!== null`, which admits `undefined`. That check runs on every render BEFORE the component's own `!currentConfig` bail-out, so a 2xx body without `config` poisoned state and threw mid-render. The card is mounted by the server page, so one odd payload took the whole screen to the error boundary — and the only remove action with it. The shape is now validated at the boundary: a bad payload is a load error, not a crash. - useInfraFleet normalised with `?? []`, which admits any non-array, and then called `.some(...)` during render. Same class, bigger blast radius: that hook drives the servers LIST, so a non-array left no route to any server at all. Neither is reachable from the API's normal responses (an unreachable host answers 502/500, which the callers already handle) — they need a proxy answering for the API or a version skew. But the failure mode is a dead page either way, and both guards were one token from correct. Removal no longer depends on any of it: the servers list grows a per-row ⋯ menu with Remove Server. DELETE /system/servers/:id was always record-only and opens no SSH, so it worked fine while the host was down; the only thing missing was a way to ask for it. Reuses the detail page's confirm copy and i18n keys, so the two surfaces can't describe removal differently and no locale drifts. Testing: the dashboard had no DOM harness at all — every test used renderToStaticMarkup, which runs no effects and so can never reach a state that depends on a failed fetch. Adds happy-dom and mounts the real page inside the real (dashboard) provider stack, stubbing only the network. Covers the six failure shapes system/check actually returns (unreachable, auth, no_server, host channel blocked, host channel unprovisioned, a non-JSON gateway 502), every ?tab= the URL can restore, reaching Remove Server while the box is down, and the list-level delete end to end. Verified the suite catches the regression: with the rate-limit fix reverted, the security-tab case fails on the original TypeError. --- apps/dashboard/package.json | 1 + .../_components/rate-limit-settings.tsx | 30 +- .../[serverId]/offline-server.render.test.tsx | 257 +++++++++++++++++ .../servers/list-remove.render.test.tsx | 153 ++++++++++ .../src/app/(dashboard)/servers/page.tsx | 264 +++++++++++------- apps/dashboard/src/hooks/useInfraFleet.ts | 19 +- bun.lock | 39 ++- 7 files changed, 654 insertions(+), 109 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/servers/[serverId]/offline-server.render.test.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/servers/list-remove.render.test.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 7c87c0fae..3f95db21c 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -45,6 +45,7 @@ "@types/node": "^22.13.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "happy-dom": "^20.11.2", "postcss": "^8.5.8", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", diff --git a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/rate-limit-settings.tsx b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/rate-limit-settings.tsx index 2125e54fe..c6568a367 100644 --- a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/rate-limit-settings.tsx +++ b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/rate-limit-settings.tsx @@ -63,9 +63,26 @@ export function RateLimitSettings({ serverId }: { serverId: string }) { setLoading(true); setLoadError(null); const res = await systemApi.getRateLimit(serverId); - setCurrentConfig(res.config); - syncDraftFromConfig(res.config); - setIsEditing(res.config.rps === 0); + // Validate the shape before it reaches state. A 2xx whose body isn't the + // config we asked for (a proxy answering for the API, a version skew) used + // to be stored as-is; the derived reads below then threw DURING RENDER, and + // this card is mounted by the server page — so one malformed response took + // the whole page to the error boundary and, with it, the only "Remove + // server" action there is. A bad payload is a load error, not a crash. + const config = res?.config; + if ( + !config || + typeof config.rps !== "number" || + typeof config.burst !== "number" || + !Array.isArray(config.whitelist) + ) { + setCurrentConfig(null); + setLoadError(t.servers.security.failedReadConfig); + return; + } + setCurrentConfig(config); + syncDraftFromConfig(config); + setIsEditing(config.rps === 0); } catch (err) { setLoadError(err instanceof Error ? err.message : t.servers.security.failedReadConfig); } finally { @@ -77,10 +94,13 @@ export function RateLimitSettings({ serverId }: { serverId: string }) { void fetchConfig(); }, [fetchConfig]); - const hasExistingLimit = currentConfig !== null && currentConfig.rps > 0; + // `!= null`, not `!== null`: these run on every render, BEFORE the + // `!currentConfig` bail-out below, so a nullish config has to read as absent + // here or the property access throws mid-render and the page dies. + const hasExistingLimit = currentConfig != null && currentConfig.rps > 0; const effectiveBurst = useAutomaticBurst ? getAutoBurst(draftRps) : draftBurst; - const hasChanges = currentConfig !== null && ( + const hasChanges = currentConfig != null && ( draftRps !== currentConfig.rps || effectiveBurst !== currentConfig.burst || !arraysEqual(draftWhitelist, currentConfig.whitelist) diff --git a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/offline-server.render.test.tsx b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/offline-server.render.test.tsx new file mode 100644 index 000000000..e2e6dce40 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/offline-server.render.test.tsx @@ -0,0 +1,257 @@ +// @vitest-environment happy-dom +/** + * The server detail page must survive an UNREACHABLE server. + * + * Reported symptom: the (dashboard) error boundary ("This page hit an error") on + * every visit to an offline server's id page — which is also the only screen + * carrying "Remove server", so a box that had gone down could not be deleted from + * the UI at all. + * + * This mounts the REAL page inside the REAL (dashboard) provider stack with a real + * DOM, and stubs only the network. Every component the page mounts for the offline + * state runs its own code: the connection banner, the overview tab with no stats, + * the connection card, and the always-mounted migrations tab (which mounts the + * whole migration wizard whenever the server has no runs). A throw in any of them + * fails here instead of reaching an operator. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { I18nProvider } from "@/components/i18n-provider"; +import { ToastProvider } from "@/components/toast"; +import { ModalProvider } from "@/context/ModalContext"; +// The REAL (dashboard) provider stack, so the tree under test sees exactly the +// contexts it sees in the app — a provider missing only here would show up as a +// crash that no user can hit. +import { DashboardProviders } from "../../providers"; + +// React 19 requires this flag before act() will drive updates. +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let searchParams = new URLSearchParams(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: () => {}, replace: () => {}, back: () => {}, refresh: () => {} }), + usePathname: () => "/servers/srv_1", + useSearchParams: () => searchParams, +})); + +// Streams are not what's under test and happy-dom has no streaming body. The +// page's own null/empty handling stays real. +vi.mock("@/hooks/useMonitorStream", () => ({ + useMonitorStream: () => ({ + stats: null, + isConnected: false, + error: null, + reconnect: () => {}, + disconnect: () => {}, + }), +})); +vi.mock("@/hooks/useSetupStream", () => ({ + useSetupStream: () => ({ + startInstall: async () => {}, + attachToSession: async () => {}, + disconnect: () => {}, + isConnected: false, + isConnecting: false, + components: [], + logs: [], + pendingPrompt: null, + respondToPrompt: async () => {}, + isDone: false, + finalStatus: null, + error: null, + }), +})); + +/** The row as the API returns it: present in the DB, unreachable over SSH. */ +const OFFLINE_SERVER = { + id: "srv_1", + name: "prod-1", + sshHost: "203.0.113.10", + sshPort: 22, + sshUser: "root", + sshAuthMethod: "key", + country: null, + isLocal: false, +}; + +/** + * The failure shapes POST system/check really answers with for a box that is not + * answering — see server-check.controller.ts. Each is a different banner path, and + * the host-channel ones are what a containerized self-hosted install actually hits. + */ +const CHECK_FAILURES: Array<{ name: string; status: number; body: unknown }> = [ + { + name: "unreachable (ETIMEDOUT)", + status: 502, + body: { error: "connection_failed", message: "connect ETIMEDOUT 203.0.113.10:22" }, + }, + { + name: "auth rejected", + status: 400, + body: { error: "auth_failed", message: "All configured authentication methods failed" }, + }, + { + name: "no server row / misconfigured", + status: 400, + body: { error: "no_server", message: "Server is not configured" }, + }, + { + name: "host channel blocked (containerized)", + status: 502, + body: { + error: "host_channel_blocked", + code: "host_channel_blocked", + message: "connect ETIMEDOUT 172.18.0.1:22", + target: "172.18.0.1:22", + hint: "The host firewall is dropping traffic from the bridge network.", + rule: "sudo ufw allow from 172.18.0.0/16 to any port 22 proto tcp", + channel: "blocked", + }, + }, + { + name: "host channel never provisioned", + status: 502, + body: { + error: "host_channel_blocked", + code: "host_channel_blocked", + message: "No host SSH endpoint is configured", + target: null, + intendedTarget: "172.18.0.1:22", + hint: null, + rule: null, + channel: "not_configured", + }, + }, + // Defensive: a proxy/gateway between the dashboard and the API can answer with + // something that is not the API's JSON at all. The page must still render. + { name: "opaque gateway error", status: 502, body: "502 Bad Gateway" }, +]; + +function stubFetch(checkFailure: { status: number; body: unknown }) { + return vi.fn(async (input: unknown) => { + const url = String( + typeof input === "string" ? input : (input as Request)?.url ?? input, + ); + const json = (body: unknown, status = 200) => + typeof body === "string" + ? new Response(body, { status, headers: { "content-type": "text/html" } }) + : new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + + if (url.includes("system/check")) return json(checkFailure.body, checkFailure.status); + if (/system\/servers\/srv_1(\?|$)/.test(url)) return json(OFFLINE_SERVER); + if (url.includes("system/servers")) return json([OFFLINE_SERVER]); + if (url.includes("system/install/session")) return json({ active: false }); + if (url.includes("migration")) return json({ runs: [] }); + // Every other call the mounted subtree makes: answer, don't hang. + return json({}); + }); +} + +let container: HTMLDivElement; +let root: Root | undefined; +let errors: unknown[] = []; + +beforeEach(() => { + errors = []; + searchParams = new URLSearchParams(); + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = undefined; + container.remove(); + vi.unstubAllGlobals(); +}); + +async function mountOfflineServer(checkFailure: { status: number; body: unknown }) { + vi.stubGlobal("fetch", stubFetch(checkFailure)); + // Imported lazily so the vi.mock factories above are installed first. + const { default: ServerDetailPage } = await import("./page"); + + await act(async () => { + root = createRoot(container, { + onUncaughtError: (e) => errors.push(e), + onCaughtError: (e) => errors.push(e), + }); + root.render( + + + + + + + + + , + ); + }); + // Let the row load + the failing health check settle. + for (let i = 0; i < 3; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } + return container.textContent ?? ""; +} + +describe("server detail page with an unreachable server", () => { + for (const failure of CHECK_FAILURES) { + it(`renders rather than crashing: ${failure.name}`, async () => { + const text = await mountOfflineServer(failure); + + expect(errors).toEqual([]); + // Proof we got past `loading` AND past the not-found branch — i.e. this is + // the real detail page, the one that carries "Remove server". + expect(text).toContain("prod-1"); + }); + } + + /** + * The actual regression: the page is the ONLY place a server can be deleted + * from, so the remove action has to be reachable while the box is down. + */ + it("exposes the remove action for a server that is down", async () => { + await mountOfflineServer(CHECK_FAILURES[0]!); + expect(errors).toEqual([]); + + // "Remove server" lives behind the ⋯ overflow menu in the header. Click each + // icon-only header button until the menu opens — which button index carries it + // is layout detail this test shouldn't pin. + const iconOnly = Array.from(container.querySelectorAll("button")).filter( + (b) => b.querySelector("svg") && !b.textContent?.trim(), + ); + expect(iconOnly.length, "header should render icon-only buttons").toBeGreaterThan(0); + + for (const button of iconOnly) { + await act(async () => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + if (container.textContent?.toLowerCase().includes("remove server")) break; + } + + expect(errors).toEqual([]); + expect(container.textContent?.toLowerCase()).toContain("remove server"); + }); + + /** + * ?tab= is persisted by changeTab, so a reload lands back on whatever tab the + * operator was last on. Each one has to survive the server being down too — + * these mount entirely different subtrees (component/module update cards, the + * terminal, exposed ports) against a host that cannot answer. + */ + for (const tab of ["components", "migrations", "security", "terminal", "github"]) { + it(`survives ?tab=${tab} while the server is down`, async () => { + searchParams = new URLSearchParams({ tab }); + const text = await mountOfflineServer(CHECK_FAILURES[0]!); + + expect(errors).toEqual([]); + expect(text).toContain("prod-1"); + }); + } +}); diff --git a/apps/dashboard/src/app/(dashboard)/servers/list-remove.render.test.tsx b/apps/dashboard/src/app/(dashboard)/servers/list-remove.render.test.tsx new file mode 100644 index 000000000..1f8ba530c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/servers/list-remove.render.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom +/** + * A server must be removable from the LIST, not only from its detail page. + * + * Deleting used to be reachable from exactly one screen — the server detail page's + * ⋯ menu — so anything that stopped that page rendering also made the server + * impossible to remove. An offline box is the case that matters: it is both the + * one you most want to delete and the one whose detail page does the most work. + * + * The delete call itself is record-only (no SSH), so this asserts the affordance + * exists and fires for a server that is NOT reachable. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { I18nProvider } from "@/components/i18n-provider"; +import { ToastProvider } from "@/components/toast"; +import { ModalProvider } from "@/context/ModalContext"; +import { DashboardProviders } from "../providers"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: () => {}, replace: () => {}, back: () => {}, refresh: () => {} }), + usePathname: () => "/servers", + useSearchParams: () => new URLSearchParams(), +})); + +const OFFLINE_SERVER = { + id: "srv_1", + name: "prod-1", + sshHost: "203.0.113.10", + sshPort: 22, + sshUser: "root", + sshAuthMethod: "key", + country: null, + isLocal: false, +}; + +/** Every DELETE the page issued, so the test can prove the action reached the API. */ +let deleted: string[] = []; + +function stubFetch() { + return vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(typeof input === "string" ? input : (input as Request)?.url ?? input); + const method = (init?.method ?? (input as Request)?.method ?? "GET").toUpperCase(); + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + + const del = url.match(/system\/servers\/([^/?]+)$/); + if (method === "DELETE" && del) { + deleted.push(del[1]!); + return json({ success: true }); + } + if (url.includes("system/servers") && url.includes("reachability")) { + // The box is down — the state this whole test is about. + return json({ reachable: false, code: "unreachable" }, 200); + } + if (/system\/servers(\?|$)/.test(url)) return json([OFFLINE_SERVER]); + return json({}); + }); +} + +let container: HTMLDivElement; +let root: Root | undefined; +const errors: unknown[] = []; + +beforeEach(() => { + errors.length = 0; + deleted = []; + vi.stubGlobal("fetch", stubFetch()); + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = undefined; + container.remove(); + vi.unstubAllGlobals(); +}); + +async function mountList() { + const { default: ServersPage } = await import("./page"); + await act(async () => { + root = createRoot(container, { + onUncaughtError: (e) => errors.push(e), + onCaughtError: (e) => errors.push(e), + }); + root.render( + + + + + + + + + , + ); + }); + for (let i = 0; i < 3; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** Click through every button whose text matches, until `done()` reports success. */ +async function clickUntil(match: RegExp, done: () => boolean) { + for (const b of Array.from(document.body.querySelectorAll("button"))) { + if (!match.test((b.textContent ?? "").toLowerCase())) continue; + await act(async () => { + b.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + if (done()) return true; + } + return done(); +} + +describe("servers list", () => { + it("offers a remove action for an unreachable server, and it deletes", async () => { + await mountList(); + expect(errors).toEqual([]); + expect(container.textContent).toContain("prod-1"); + + // Open the row's ⋯ menu (icon-only button inside the row). + const iconOnly = Array.from(container.querySelectorAll("button")).filter( + (b) => b.querySelector("svg") && !b.textContent?.trim(), + ); + let menuOpened = false; + for (const b of iconOnly) { + await act(async () => { + b.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + if (/remove server/i.test(document.body.textContent ?? "")) { + menuOpened = true; + break; + } + } + expect(menuOpened, "row ⋯ menu should expose Remove Server").toBe(true); + + // Remove → confirm. The modal renders in a portal, so search the document. + await clickUntil(/remove server/, () => /are you sure/i.test(document.body.textContent ?? "")); + await clickUntil(/^remove$/, () => deleted.length > 0); + + expect(errors).toEqual([]); + expect(deleted).toEqual(["srv_1"]); + }); +}); diff --git a/apps/dashboard/src/app/(dashboard)/servers/page.tsx b/apps/dashboard/src/app/(dashboard)/servers/page.tsx index 989d9d9fb..afdadc309 100644 --- a/apps/dashboard/src/app/(dashboard)/servers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/servers/page.tsx @@ -22,13 +22,15 @@ import { Layers, MapPin, HardDrive, + Trash2, } from "lucide-react"; -import { systemApi } from "@/lib/api"; +import { systemApi, getApiErrorMessage } from "@/lib/api"; import type { ContainerApplyActive, ContainerApplyIntent } from "@/lib/api/system"; import { PageContainer } from "@/components/ui/PageContainer"; import DropdownMenu from "@/components/ui/DropdownMenu"; import { Tabs, type TabDef } from "@/components/ui/Tabs"; import { usePlatform } from "@/context/PlatformContext"; +import { useModal } from "@/context/ModalContext"; import { useI18n, interpolate } from "@/components/i18n-provider"; import { useToast } from "@/components/toast"; import { useInfraFleet, type InfraSegment } from "@/hooks/useInfraFleet"; @@ -80,6 +82,7 @@ export default function ServersPage() { const router = useRouter(); const { selfHosted, deployMode, isServerHost, hostControlEnabled } = usePlatform(); const { toast } = useToast(); + const { showModal, hideModal } = useModal(); const isDesktop = deployMode === "desktop"; /** Managed edge/mail containers exist only where we operate the boxes. */ const infraEnabled = selfHosted || isDesktop; @@ -146,6 +149,55 @@ export default function ServersPage() { } }, [fetchServers, router, toast, t]); + /** + * Remove a server from the LIST, not just from its detail page. + * + * The detail page's ⋯ menu used to be the only way to delete a server, which + * made deletion hostage to that page rendering: a box that had gone down (or any + * card on that page hitting a bad payload) took the whole screen to the error + * boundary, and the server became impossible to remove from the UI at all. + * + * The delete itself never needed the server: DELETE /system/servers/:id is a + * record-only delete that opens no SSH connection, so it works fine while the + * host is unreachable — the only thing that was ever missing was a way to ask + * for it. Same copy as the detail page's confirm, so the two can't tell an + * operator different things about what removal does. + */ + const removeServer = useCallback( + (server: { id: string; name?: string | null }) => { + const modalId = showModal({ + title: t.servers.detail.removeServer, + message: t.servers.detail.removeServerMessage, + icon: "warning", + buttons: [ + { + label: t.servers.detail.cancel, + variant: "secondary", + onClick: () => hideModal(modalId), + }, + { + label: t.servers.detail.remove, + variant: "danger", + onClick: async () => { + try { + await systemApi.deleteServerEntry(server.id); + hideModal(modalId); + toast("success", t.servers.detail.toastServerRemoved); + await fetchServers(); + } catch (err) { + toast( + "error", + getApiErrorMessage(err, t.servers.detail.toastFailedRemoveServer), + ); + } + }, + }, + ], + }); + }, + [fetchServers, hideModal, showModal, toast, t], + ); + // Real reachability: seed every server to "checking", then probe each in // parallel and flip its dot as the probe resolves (mirrors the tunnel fan-out). useEffect(() => { @@ -417,104 +469,126 @@ export default function ServersPage() { ] : []; return ( - - {/* Avatar — full country flag when we can geolocate the IP, else glyph. - Fixed 36px slot keeps the name column aligned across rows. */} - {(() => { - const Flag = server.country ? FLAGS[server.country] : undefined; - return Flag ? ( -
- -
- ) : ( -
- -
- ); - })()} - - {/* Name + host (fixed column — keeps meta aligned, no dead gap) */} -
-

- {server.name} - {server.isLocal && ( - - {t.servers.list.thisServer} + // `group` and the key live on the wrapper so the row overflow + // menu is a SIBLING of the link, not nested inside it (a button + // inside an anchor is invalid, and every menu click would + // navigate). group-hover styling inside the link is unaffected. +

+ + {/* Avatar — full country flag when we can geolocate the IP, else glyph. + Fixed 36px slot keeps the name column aligned across rows. */} + {(() => { + const Flag = server.country ? FLAGS[server.country] : undefined; + return Flag ? ( +
+ +
+ ) : ( +
+ +
+ ); + })()} + + {/* Name + host (fixed column — keeps meta aligned, no dead gap) */} +
+

+ {server.name} + {server.isLocal && ( + + {t.servers.list.thisServer} + + )} +

+

+ {server.isLocal ? t.servers.list.currentHost : {server.host}} +

+
+ + {/* Meta chips */} +
+ {/* Nothing deployed → no chip at all. A greyed-out "0" beside + a layers glyph is noise that reads as an error. With + projects, the count is spelled out ("1 project") instead + of leaving an icon to carry the meaning. */} + {server.projectCount > 0 && ( + + {interpolate( + server.projectCount === 1 + ? t.servers.list.projectCountOne + : t.servers.list.projectCountMany, + { n: String(server.projectCount) }, + )} )} -

-

- {server.isLocal ? t.servers.list.currentHost : {server.host}} -

-
- - {/* Meta chips */} -
- {/* Nothing deployed → no chip at all. A greyed-out "0" beside - a layers glyph is noise that reads as an error. With - projects, the count is spelled out ("1 project") instead - of leaving an icon to carry the meaning. */} - {server.projectCount > 0 && ( - - {interpolate( - server.projectCount === 1 - ? t.servers.list.projectCountOne - : t.servers.list.projectCountMany, - { n: String(server.projectCount) }, - )} - - )} - {downParts.length > 0 ? ( - - {downParts.join(" · ")} - - ) : comp && comp.applying > 0 ? ( - // Mid-apply outranks the drift it is fixing: the row would - // otherwise keep offering "1 update" for a swap already running. - - - {ic.chipUpdating} - - ) : comp && comp.updates > 0 ? ( - - {interpolate(comp.updates === 1 ? ic.chipUpdateOne : ic.chipUpdates, { - n: String(comp.updates), - })} - - ) : null} - {authLabel && ( - - - {authLabel} - - )} - {isDesktop && fwd > 0 && ( - - - {interpolate(t.servers.list.forwarding, { n: String(fwd) })} + {downParts.length > 0 ? ( + + {downParts.join(" · ")} + + ) : comp && comp.applying > 0 ? ( + // Mid-apply outranks the drift it is fixing: the row would + // otherwise keep offering "1 update" for a swap already running. + + + {ic.chipUpdating} + + ) : comp && comp.updates > 0 ? ( + + {interpolate(comp.updates === 1 ? ic.chipUpdateOne : ic.chipUpdates, { + n: String(comp.updates), + })} + + ) : null} + {authLabel && ( + + + {authLabel} + + )} + {isDesktop && fwd > 0 && ( + + + {interpolate(t.servers.list.forwarding, { n: String(fwd) })} + + )} +
+ + {/* Status state + arrow */} +
+ + + {t.servers.list[state]} - )} + +
+ + {/* Row actions. Reachable whatever state the box is in — that's + the point: a server you can't connect to is exactly the one + you need to be able to remove. */} +
+ , + variant: "danger", + onClick: () => removeServer(server), + }, + ]} + />
- - {/* Status state + arrow */} -
- - - {t.servers.list[state]} - - -
- +
); })}
diff --git a/apps/dashboard/src/hooks/useInfraFleet.ts b/apps/dashboard/src/hooks/useInfraFleet.ts index a5d6ecf13..e06e90b23 100644 --- a/apps/dashboard/src/hooks/useInfraFleet.ts +++ b/apps/dashboard/src/hooks/useInfraFleet.ts @@ -50,6 +50,19 @@ const sameKeys = (a: Set, b: Set): boolean => * `enabled` is the self-hosted/desktop gate: on cloud these endpoints are * `assertNotCloud`, so we never call them. */ +/** + * Coerce a container-groups response to an array. + * + * `?? []` is not enough: it admits any non-null value, and a 2xx body that isn't + * the array we asked for (a proxy answering for the API, a version skew) then + * reached `.some(...)` DURING RENDER — which throws, and this hook is called by + * the servers LIST page, so a single odd payload took the whole fleet view to the + * error boundary and left no route to any server. + */ +function asGroups(value: unknown): ServerContainerGroup[] { + return Array.isArray(value) ? (value as ServerContainerGroup[]) : []; +} + export function useInfraFleet(enabled: boolean) { const [groups, setGroups] = useState(null); const [scanning, setScanning] = useState(false); @@ -77,7 +90,7 @@ export function useInfraFleet(enabled: boolean) { const load = useCallback(async (): Promise => { if (!enabled) return []; try { - const fresh = await systemApi.listAllContainers(); + const fresh = asGroups(await systemApi.listAllContainers()); if (alive.current) setGroups(fresh); return fresh; } catch { @@ -90,7 +103,7 @@ export function useInfraFleet(enabled: boolean) { if (!enabled) return; setScanning(true); try { - setGroups(await systemApi.scanAllContainers()); + setGroups(asGroups(await systemApi.scanAllContainers())); markInfraScanned(); } catch { // Best-effort: one unreachable box shouldn't wipe the cached view. @@ -117,7 +130,7 @@ export function useInfraFleet(enabled: boolean) { setScanning(true); try { const fresh = await systemApi.scanAllContainers(); - if (!cancelled) setGroups(fresh); + if (!cancelled) setGroups(asGroups(fresh)); } catch { /* cached view stays */ } finally { diff --git a/bun.lock b/bun.lock index 66a1761d6..aa89bdcef 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/api": { "name": "@repo/api", - "version": "0.6.1", + "version": "0.6.5", "dependencies": { "@better-auth/drizzle-adapter": "^1.5.4", "@hono/node-server": "^1.19.15", @@ -51,7 +51,7 @@ }, "apps/cli": { "name": "openship", - "version": "0.6.1", + "version": "0.6.5", "bin": { "openship": "./dist/node-entry.js", }, @@ -109,6 +109,7 @@ "@types/node": "^22.13.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "happy-dom": "^20.11.2", "postcss": "^8.5.8", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", @@ -117,7 +118,7 @@ }, "apps/desktop": { "name": "@repo/desktop", - "version": "0.6.1", + "version": "0.6.5", "dependencies": { "@repo/core": "workspace:*", "@repo/onboarding": "workspace:*", @@ -140,11 +141,11 @@ }, "apps/email": { "name": "@repo/email", - "version": "0.6.1", + "version": "0.6.5", }, "apps/web": { "name": "@repo/web", - "version": "0.6.1", + "version": "0.6.5", "dependencies": { "@repo/core": "workspace:*", "@repo/ui": "workspace:*", @@ -1074,10 +1075,14 @@ "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], "@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1244,6 +1249,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], "bullmq": ["bullmq@5.70.4", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.9.3", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.4", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg=="], @@ -1502,7 +1509,7 @@ "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1742,6 +1749,8 @@ "gsap": ["gsap@3.14.2", "", {}, "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA=="], + "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -2938,6 +2947,8 @@ "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -3120,6 +3131,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@types/ws/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "api/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3136,6 +3149,8 @@ "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-image-size/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "bullmq/ioredis": ["ioredis@5.9.3", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA=="], "bullmq/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -3228,6 +3243,10 @@ "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "happy-dom/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + + "happy-dom/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "load-json-file/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], @@ -3290,6 +3309,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-type/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -3430,6 +3451,8 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "api/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "api/ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -3446,6 +3469,8 @@ "appdmg/execa/npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="], + "buffer-image-size/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "bullmq/ioredis/@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="], "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3540,6 +3565,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "happy-dom/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "log-update/ansi-escapes/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],