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) */}
+
+ {/* 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) },
+ )}
)}
-
-
- {/* 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. */}
+