feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

@@ -1,6 +1,6 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
ArrowDown,
@@ -20,7 +20,7 @@ import {
refreshFleetInventory,
type FleetRow,
type FleetInventoryEntry,
} from "@/api/fleets-api";
} from "./fleets-api";
type SortKey =
| "hostName"
+2 -2
View File
@@ -1,6 +1,6 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
Boxes,
@@ -76,7 +76,7 @@ import {
type FleetInventoryEntry,
type FleetPackageAction,
type FleetShareHostResult,
} from "@/api/fleets-api";
} from "./fleets-api";
import type { SSHHost } from "@/types/index";
const SHARE_PERMISSION_LEVELS: SharePermissionLevel[] = [
+300
View File
@@ -0,0 +1,300 @@
import {
authApi,
handleApiError,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios";
export interface FleetRow {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string[];
syncId: string | null;
createdAt: string;
updatedAt: string;
memberCount: number;
}
export interface FleetMemberRow {
id: number;
name: string;
ip: string;
tags: string[];
static: boolean;
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
}
export interface FleetHostResult {
hostId: number;
hostName: string;
success: boolean;
output?: string;
error?: string;
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryEntry {
hostId: number;
hostName: string;
inventory: FleetInventoryRecord | null;
}
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/plugin-api/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
}
}
export async function createFleet(fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/plugin-api/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
}
}
export async function updateFleet(
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(
`/plugin-api/fleets/${fleetId}`,
fleetData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
}
}
export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
}
}
export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
}
}
export async function addFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/members`,
{
hostId,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
}
}
export async function removeFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove fleet member");
}
}
export async function runFleetCommand(
fleetId: number,
command: string,
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/execute`,
{
command,
...(inputValues ? { inputValues } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
}
}
export async function pushFleetFile(
fleetId: number,
file: File,
remotePath: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return response.data;
} catch (error) {
throw handleApiError(error, "push file to fleet");
}
}
export async function pullFleetFile(
fleetId: number,
remotePath: string,
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const resultsHeader = response.headers["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = response.headers["content-disposition"] as
string | undefined;
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
} catch (error) {
throw handleApiError(error, "pull file from fleet");
}
}
export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
}
}
export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
}
}
export async function runFleetPackageAction(
fleetId: number,
action: FleetPackageAction,
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/packages`,
{
action,
...(packageName ? { package: packageName } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
}
}
export interface FleetShareHostResult {
hostId: number;
shared: boolean;
reason?: string;
}
export async function shareFleet(
fleetId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
): Promise<{
success: boolean;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
hostsShared: number;
hostsTotal: number;
hostResults: FleetShareHostResult[];
}> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share fleet");
}
}
-40
View File
@@ -1,40 +0,0 @@
/**
* 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);
}
+55
View File
@@ -0,0 +1,55 @@
import { Boxes } from "lucide-react";
import type {
PanelProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { FleetsPanel } from "./FleetsPanel";
import { FleetInventoryTab } from "./FleetInventoryTab";
import { listFleets } from "./fleets-api";
const INVENTORY_TAB = "fleet-inventory";
function Panel({ active, shell }: PanelProps) {
return (
<FleetsPanel
active={active}
onOpenFleetInventory={(fleetId) =>
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
}
/>
);
}
function InventoryTab({ tab, isVisible }: TabProps) {
const fleetId = tab.data?.fleetId;
return (
<FleetInventoryTab
fleetId={typeof fleetId === "number" ? fleetId : undefined}
isVisible={isVisible}
/>
);
}
export function activate(app: TermixApp): void {
app.registerRailItem({
id: "fleets",
icon: Boxes,
titleKey: "nav.fleets",
after: "macros",
order: 10,
});
// Kept mounted so a fleet being edited survives switching panels.
app.registerPanel("fleets", Panel, { keepMounted: true });
app.registerTab(INVENTORY_TAB, InventoryTab, {
icon: Boxes,
titleKey: "nav.fleets",
singleton: true,
hostless: true,
});
// Other plugins (automations' host picker) list fleets through this.
app.registerAction("fleets.list", () => listFleets());
}