mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Fleets - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason proxmox and ai do (see src/backend/plugins/first-party.ts): its
|
||||
* execute/transfer routes resolve and connect to many hosts concurrently over
|
||||
* the shared SSH pool via resolveHostById and withConnection, read/write
|
||||
* files over SFTP, and stream a zip archive back for pull requests. None of
|
||||
* that fits the worker ctx -- ctx.hosts is a 13-field read-only view with no
|
||||
* fleets surface at all, and ctx.http.route replies with a single postMessage
|
||||
* value rather than a stream.
|
||||
*
|
||||
* Like proxmox, this plugin does not own a live transport (no WebSocket
|
||||
* server, no dedicated port), so it registers its Express router with the
|
||||
* always-mounted /fleets dispatcher in
|
||||
* src/backend/database/routes/fleet-dispatch.ts instead of getting its own
|
||||
* port.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/database/routes/fleet-routes.ts,
|
||||
* moved here and rewritten from `export default router` into
|
||||
* startFleetsService()/stopFleetsService(), called from
|
||||
* activate()/deactivate() below. The fleets/fleetMembers/fleetInventory
|
||||
* tables and their repositories stay in core (src/backend/database/db/schema.ts,
|
||||
* src/backend/database/repositories/fleet-repository.ts,
|
||||
* fleet-inventory-repository.ts) -- this plugin system has no owned-table
|
||||
* migration mechanism of its own, and these tables carry FKs into core's
|
||||
* hosts/users tables.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for routes.js sits right next to this .mjs, and core modules resolve
|
||||
* via ../../../backend/backend/... from there (dist/plugins/fleets/backend
|
||||
* -> dist/backend/backend). Under vitest/dev nothing imports this plugin
|
||||
* directly (the loader loads in-process plugins with a plain dynamic import,
|
||||
* no tsx loader involved), so the only layout that needs to resolve here is
|
||||
* the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startFleetsService();
|
||||
ctx.log.info("Fleets router registered at /fleets");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopFleetsService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,419 @@
|
||||
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
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Frontend half of the fleets plugin.
|
||||
*
|
||||
* FleetsPanel is a left-sidebar railView panel (opened from the rail, not a
|
||||
* Tab), the same shape as HostsPanel/CredentialsPanel/AiPanel. None of those
|
||||
* go through registerRailItem/registerTabComponent either -- per
|
||||
* src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is
|
||||
* rendered directly by AppShell.tsx rather than registered at runtime, the
|
||||
* same way ai's AiPanel is. FleetsPanel is reached today through a direct
|
||||
* relative import in AppShell.tsx (../../plugins/fleets/frontend/FleetsPanel),
|
||||
* not through register() below.
|
||||
*
|
||||
* register()/unregister() still follow the same shape every other plugin's
|
||||
* frontend entry does, so this is ready the moment a real frontend plugin
|
||||
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
|
||||
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||
* "there is no plugin loader yet".
|
||||
*
|
||||
* The fleet-inventory tab (opened from within FleetsPanel) is wired
|
||||
* separately in src/ui/shell/tabUtils.tsx, the same way docker/host-metrics
|
||||
* tabs are -- a plugin tab id that already exists in the built-in TabType
|
||||
* union goes through the ordinary tabSurfaceLoaders/switch wiring there, not
|
||||
* through registerTabComponent (which is for tab ids outside that union).
|
||||
*/
|
||||
|
||||
export const id = "fleets";
|
||||
export const railViewId = "fleets";
|
||||
|
||||
export async function register({ registerRailItem, icons }) {
|
||||
registerRailItem?.({
|
||||
id: railViewId,
|
||||
icon: icons.Boxes,
|
||||
labelKey: "nav.fleets",
|
||||
separatorAfter: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregister({ unregisterRailItem }) {
|
||||
unregisterRailItem?.(railViewId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"name": "Fleets",
|
||||
"version": "1.0.0",
|
||||
"description": "Group hosts into fleets by static membership or tag rules, and run commands, package actions, and file transfers across every member at once.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Infrastructure",
|
||||
"icon": "Boxes",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"sidecars": []
|
||||
}
|
||||
Reference in new issue
Block a user