mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move fleets into a first-party plugin
This commit is contained in:
1 parent
76b2e5739a
commit
a6f6a0439c
17 files changed
+281
-38
No files matched your search
@@ -10,7 +10,7 @@ import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import fleetRoutes from "./routes/fleet-routes.js";
|
||||
import fleetsDispatch from "./routes/fleet-dispatch.js";
|
||||
import workspaceRoutes from "./routes/workspaces.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
@@ -1753,7 +1753,7 @@ app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/fleets", fleetRoutes);
|
||||
app.use("/fleets", fleetsDispatch);
|
||||
app.use("/workspaces", workspaceRoutes);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Mounted at /fleets. Fleets ships as a first-party in-process plugin
|
||||
// (plugins/fleets) rather than a route file imported directly here, so this
|
||||
// module exists to keep /fleets/* working at its original path without
|
||||
// handing the plugin a reference to the shared app instance. The plugin
|
||||
// runtime registers its router via registerFleetsRouter when it activates,
|
||||
// and clears it on deactivate, so a disabled plugin falls back to the 404
|
||||
// below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Fleets plugin's own), and every
|
||||
// route in that router already applies authenticateJWT/requireDataAccess
|
||||
// itself, the same as every other router mounted alongside it here (/users,
|
||||
// /host, /snippets, ...). /plugin-api gates at the mount point because it
|
||||
// forwards to arbitrary third-party plugin code that cannot be assumed to
|
||||
// gate itself; that reasoning does not apply to a single first-party router
|
||||
// that already does.
|
||||
|
||||
import type { Request, Response, NextFunction, Router } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerFleetsRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Fleets plugin router", {
|
||||
operation: "fleets_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterFleetsRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Fleets plugin router", {
|
||||
operation: "fleets_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function fleetsDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Fleets is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -68,6 +68,13 @@
|
||||
* /connect-host/:hostId route does multi-step credential/tunnel resolution
|
||||
* (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw
|
||||
* net.Socket plumbing that cannot cross a structured-clone boundary.
|
||||
*
|
||||
* fleets is here for the same reason as ai and proxmox: it does not own a
|
||||
* transport, but its execute/transfer routes resolve and connect to many
|
||||
* hosts concurrently via resolveHostById and the shared SSH pool, stream SFTP
|
||||
* reads/writes and a zip archive for its push/pull routes, and buffer
|
||||
* multipart file uploads. None of that fits the worker's single-exec
|
||||
* ctx.ssh or single-postMessage-reply ctx.http.route.
|
||||
*/
|
||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"ssh-terminal",
|
||||
@@ -76,6 +83,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"ai",
|
||||
"proxmox",
|
||||
"remote-desktop",
|
||||
"fleets",
|
||||
]);
|
||||
|
||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||
|
||||
@@ -299,11 +299,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||
// so disabling any of them stops its WS/HTTP server. See
|
||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||
// AI, Proxmox and Remote Desktop are also absent for the same reason:
|
||||
// each plugin registers its router with its own dispatcher in
|
||||
// database.ts on activate (/ai, /proxmox, /guacamole), so disabling any
|
||||
// of them makes its routes 404 instead of leaving a dead import here.
|
||||
// See plugins/ai, plugins/proxmox and plugins/remote-desktop.
|
||||
// AI, Proxmox, Remote Desktop and Fleets are also absent for the same
|
||||
// reason: each plugin registers its router with its own dispatcher in
|
||||
// database.ts on activate (/ai, /proxmox, /guacamole, /fleets), so
|
||||
// disabling any of them makes its routes 404 instead of leaving a dead
|
||||
// import here. See plugins/ai, plugins/proxmox, plugins/remote-desktop
|
||||
// and plugins/fleets.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
|
||||
@@ -244,3 +244,42 @@ describe("bundled remote-desktop plugin", () => {
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled fleets plugin", () => {
|
||||
it("ships a directory with a manifest and both entry points", () => {
|
||||
const dir = path.join(getBundledPluginsDir(), "fleets");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has a manifest that passes the real validator", () => {
|
||||
const { manifest, errors } = parseManifest(readBundledManifest("fleets"));
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe("fleets");
|
||||
expect(manifest?.category).toBe("Infrastructure");
|
||||
});
|
||||
|
||||
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("fleets"));
|
||||
|
||||
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||
expect(isFirstParty("fleets")).toBe(true);
|
||||
expect(runsInProcess("fleets", manifest!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports activate and deactivate from its backend entry", () => {
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"fleets",
|
||||
"backend",
|
||||
"index.mjs",
|
||||
);
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
|
||||
expect(source).toMatch(/export async function activate/);
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
@@ -39,10 +39,11 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"ai",
|
||||
"docker",
|
||||
"fleets",
|
||||
"host-metrics",
|
||||
"proxmox",
|
||||
"remote-desktop",
|
||||
@@ -50,13 +51,14 @@ describe("first-party allowlist", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop and nothing else", () => {
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("host-metrics")).toBe(true);
|
||||
expect(isFirstParty("ai")).toBe(true);
|
||||
expect(isFirstParty("proxmox")).toBe(true);
|
||||
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||
expect(isFirstParty("fleets")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
|
||||
+7
-5
@@ -135,11 +135,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const {
|
||||
default: router,
|
||||
parseInventoryProbe,
|
||||
buildRemoveCommand,
|
||||
} = await import("../../../database/routes/fleet-routes.js");
|
||||
vi.mock("../../../database/routes/fleet-dispatch.js", () => ({
|
||||
registerFleetsRouter: vi.fn(),
|
||||
unregisterFleetsRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
const { router, parseInventoryProbe, buildRemoveCommand } =
|
||||
await import("../../../../../plugins/fleets/backend/routes.js");
|
||||
|
||||
function findLayer(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
+3
-1
@@ -92,7 +92,9 @@ const MacrosPanel = lazy(() =>
|
||||
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
||||
);
|
||||
const FleetsPanel = lazy(() =>
|
||||
import("@/sidebar/FleetsPanel").then((m) => ({ default: m.FleetsPanel })),
|
||||
import("../../plugins/fleets/frontend/FleetsPanel").then((m) => ({
|
||||
default: m.FleetsPanel,
|
||||
})),
|
||||
);
|
||||
const WorkspacesPanel = lazy(() =>
|
||||
import("@/sidebar/WorkspacesPanel").then((m) => ({
|
||||
|
||||
@@ -42,6 +42,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
||||
"ssh-terminal": ["terminal"],
|
||||
"remote-desktop": ["rdp", "vnc", "telnet"],
|
||||
ai: ["ai"],
|
||||
fleets: ["fleet-inventory"],
|
||||
};
|
||||
|
||||
let pluginEnabled = new Map<string, boolean>();
|
||||
|
||||
@@ -145,7 +145,7 @@ const Serial = lazy(() =>
|
||||
})),
|
||||
);
|
||||
const FleetInventoryTab = lazy(() =>
|
||||
import("@/sidebar/FleetInventoryTab").then((m) => ({
|
||||
import("../../../plugins/fleets/frontend/FleetInventoryTab").then((m) => ({
|
||||
default: m.FleetInventoryTab,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
listFleets,
|
||||
getFleetInventory,
|
||||
refreshFleetInventory,
|
||||
type FleetRow,
|
||||
type FleetInventoryEntry,
|
||||
} from "@/api/fleets-api";
|
||||
|
||||
type SortKey =
|
||||
| "hostName"
|
||||
| "osPrettyName"
|
||||
| "kernel"
|
||||
| "architecture"
|
||||
| "uptimeSeconds"
|
||||
| "packageManager"
|
||||
| "collectedAt";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds === null) return "-";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function compareValues(
|
||||
a: string | number | null,
|
||||
b: string | number | null,
|
||||
): number {
|
||||
if (a === null && b === null) return 0;
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
label,
|
||||
column,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
column: SortKey;
|
||||
sortKey: SortKey;
|
||||
sortDirection: SortDirection;
|
||||
onSort: (column: SortKey) => void;
|
||||
}) {
|
||||
const active = sortKey === column;
|
||||
return (
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="size-3" />
|
||||
) : (
|
||||
<ArrowDown className="size-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function FleetInventoryTab({
|
||||
fleetId,
|
||||
isVisible = true,
|
||||
}: {
|
||||
fleetId?: number;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [fleets, setFleets] = useState<FleetRow[]>([]);
|
||||
const [selectedFleetId, setSelectedFleetId] = useState<number | undefined>(
|
||||
fleetId,
|
||||
);
|
||||
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("hostName");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
|
||||
useEffect(() => {
|
||||
if (fleetId !== undefined) setSelectedFleetId(fleetId);
|
||||
}, [fleetId]);
|
||||
|
||||
useEffect(() => {
|
||||
listFleets()
|
||||
.then(setFleets)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fleets.length) return;
|
||||
if (selectedFleetId === undefined) {
|
||||
setSelectedFleetId(fleets[0].id);
|
||||
}
|
||||
}, [fleets, selectedFleetId]);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
if (selectedFleetId === undefined) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getFleetInventory(selectedFleetId);
|
||||
setEntries(data);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
toast.error(message || t("newUi.sidebar.fleets.inventoryLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedFleetId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
loadInventory();
|
||||
}, [loadInventory, isVisible]);
|
||||
|
||||
async function handleRefresh() {
|
||||
if (selectedFleetId === undefined) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const { results } = await refreshFleetInventory(selectedFleetId);
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
if (failed > 0) {
|
||||
toast.warning(
|
||||
t("newUi.sidebar.fleets.commandPartialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("newUi.sidebar.fleets.inventoryRefreshed"));
|
||||
}
|
||||
await loadInventory();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
toast.error(message || t("newUi.sidebar.fleets.inventoryRefreshFailed"));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSort(column: SortKey) {
|
||||
if (sortKey === column) {
|
||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const selectedFleet = fleets.find((f) => f.id === selectedFleetId);
|
||||
|
||||
const filteredSorted = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = entries.filter(
|
||||
(e) =>
|
||||
!q ||
|
||||
e.hostName.toLowerCase().includes(q) ||
|
||||
(e.inventory?.osPrettyName ?? "").toLowerCase().includes(q) ||
|
||||
(e.inventory?.hostname ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortKey) {
|
||||
case "hostName":
|
||||
cmp = compareValues(a.hostName, b.hostName);
|
||||
break;
|
||||
case "osPrettyName":
|
||||
cmp = compareValues(
|
||||
a.inventory?.osPrettyName ?? null,
|
||||
b.inventory?.osPrettyName ?? null,
|
||||
);
|
||||
break;
|
||||
case "kernel":
|
||||
cmp = compareValues(
|
||||
a.inventory?.kernel ?? null,
|
||||
b.inventory?.kernel ?? null,
|
||||
);
|
||||
break;
|
||||
case "architecture":
|
||||
cmp = compareValues(
|
||||
a.inventory?.architecture ?? null,
|
||||
b.inventory?.architecture ?? null,
|
||||
);
|
||||
break;
|
||||
case "uptimeSeconds":
|
||||
cmp = compareValues(
|
||||
a.inventory?.uptimeSeconds ?? null,
|
||||
b.inventory?.uptimeSeconds ?? null,
|
||||
);
|
||||
break;
|
||||
case "packageManager":
|
||||
cmp = compareValues(
|
||||
a.inventory?.packageManager ?? null,
|
||||
b.inventory?.packageManager ?? null,
|
||||
);
|
||||
break;
|
||||
case "collectedAt":
|
||||
cmp = compareValues(
|
||||
a.inventory?.collectedAt ?? null,
|
||||
b.inventory?.collectedAt ?? null,
|
||||
);
|
||||
break;
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
}, [entries, search, sortKey, sortDirection]);
|
||||
|
||||
const collectedCount = entries.filter((e) => e.inventory !== null).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.fleets.tabInventory")}
|
||||
</span>
|
||||
|
||||
<select
|
||||
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
|
||||
value={selectedFleetId ?? ""}
|
||||
onChange={(e) => setSelectedFleetId(Number(e.target.value))}
|
||||
>
|
||||
{fleets.map((fleet) => (
|
||||
<option key={fleet.id} value={fleet.id}>
|
||||
{fleet.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{selectedFleet && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("newUi.sidebar.fleets.memberCount", {
|
||||
count: entries.length,
|
||||
})}{" "}
|
||||
·{" "}
|
||||
{t("newUi.sidebar.fleets.inventoryCollectedCount", {
|
||||
count: collectedCount,
|
||||
total: entries.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("newUi.sidebar.fleets.searchHosts")}
|
||||
className="h-7 w-48 pl-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || selectedFleetId === undefined}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
{t("newUi.sidebar.fleets.refreshInventory")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : fleets.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.fleets.noFleets")}
|
||||
</div>
|
||||
) : filteredSorted.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.fleets.noMembers")}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryHost")}
|
||||
column="hostName"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryOs")}
|
||||
column="osPrettyName"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryKernel")}
|
||||
column="kernel"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryArch")}
|
||||
column="architecture"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryUptime")}
|
||||
column="uptimeSeconds"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryPackageManager")}
|
||||
column="packageManager"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t("newUi.sidebar.fleets.inventoryCollectedAt")}
|
||||
column="collectedAt"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredSorted.map((entry) => (
|
||||
<tr
|
||||
key={entry.hostId}
|
||||
className="transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0",
|
||||
entry.inventory
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 truncate font-semibold">
|
||||
{entry.hostName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{entry.inventory?.osPrettyName ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">
|
||||
{entry.inventory?.kernel ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{entry.inventory?.architecture ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{formatUptime(entry.inventory?.uptimeSeconds ?? null)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{entry.inventory?.packageManager ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{entry.inventory?.collectedAt
|
||||
? new Date(entry.inventory.collectedAt).toLocaleString()
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user