mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
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"
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user