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

@@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { getCredentials } from "@/main-axios";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostProxmoxTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<
{ id: number; name: string; username: string | null }[]
>([]);
useEffect(() => {
getCredentials()
.then((res: unknown) => {
const raw =
(res as { credentials?: unknown })?.credentials ?? res ?? [];
const list = (Array.isArray(raw) ? raw : []).map(
(c: Record<string, unknown>) => ({
id: c.id as number,
name: c.name as string,
username: (c.username as string | null) ?? null,
}),
);
setCredentials(list);
})
.catch(() => {});
}, []);
const cfg = form.proxmoxConfig ?? {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
autoSyncEnabled: false,
syncIntervalMinutes: 15,
markMissingGuests: true,
};
const lastSyncResult = cfg.lastSyncResult;
const lastSyncSummary = lastSyncResult
? t("hosts.proxmoxLastSyncSummary", {
created: lastSyncResult.created,
updated: lastSyncResult.updated,
markedMissing: lastSyncResult.markedMissing,
skipped: lastSyncResult.skipped,
})
: t("hosts.proxmoxLastSyncNoResult");
const lastSyncDescription = cfg.lastSyncAt
? `${new Date(cfg.lastSyncAt).toLocaleString()} · ${lastSyncSummary}`
: t("hosts.proxmoxLastSyncNever");
return (
<SectionCard
title={t("hosts.proxmoxIntegration")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmox")}
description={
<>
{t("hosts.enableProxmoxDesc")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.enableProxmox}
onChange={(v) => setField("enableProxmox", v)}
/>
</SettingRow>
{form.enableProxmox && (
<>
<SettingRow
label={t("hosts.proxmoxDefaultAuthType")}
description={t("hosts.proxmoxDefaultAuthTypeDesc")}
>
<Select2
value={cfg.defaultAuthType ?? "password"}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultAuthType: e.target.value,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="password">{t("hosts.authTypePassword")}</option>
<option value="key">{t("hosts.authTypeKey")}</option>
<option value="credential">
{t("hosts.authTypeCredential")}
</option>
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
<option value="stepca">{t("hosts.authTypeStepca")}</option>
<option value="none">{t("hosts.authTypeNone")}</option>
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDefaultCredential")}
description={t("hosts.proxmoxDefaultCredentialDesc")}
>
<Select2
value={cfg.defaultCredentialId ?? ""}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultCredentialId: e.target.value
? Number(e.target.value)
: null,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.none")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxWindowsDetection")}
description={t("hosts.proxmoxWindowsDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.windowsPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
windowsPatterns: e.target.value,
})
}
placeholder="win, windows"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDockerDetection")}
description={t("hosts.proxmoxDockerDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.dockerPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
dockerPatterns: e.target.value,
})
}
placeholder="docker"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxPreferredRanges")}
description={t("hosts.proxmoxPreferredRangesDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.preferredPrefixes}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
preferredPrefixes: e.target.value,
})
}
placeholder="10., 192.168."
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxAutoSync")}
description={t("hosts.proxmoxAutoSyncDesc")}
>
<FakeSwitch
checked={cfg.autoSyncEnabled === true}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
autoSyncEnabled: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxSyncInterval")}
description={t("hosts.proxmoxSyncIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={5}
value={cfg.syncIntervalMinutes ?? 15}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
syncIntervalMinutes: Math.max(
5,
Number.parseInt(e.target.value || "15", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxMarkMissing")}
description={t("hosts.proxmoxMarkMissingDesc")}
>
<FakeSwitch
checked={cfg.markMissingGuests !== false}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
markMissingGuests: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxLastSync")}
description={lastSyncDescription}
>
<span
className={`text-xs ${
cfg.lastSyncStatus === "error"
? "text-destructive"
: "text-muted-foreground"
}`}
title={cfg.lastSyncError ?? undefined}
>
{cfg.lastSyncStatus
? t(`hosts.proxmoxLastSyncStatus.${cfg.lastSyncStatus}`)
: t("hosts.proxmoxLastSyncStatus.pending")}
</span>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
@@ -1,6 +1,6 @@
import { useRef, useState } from "react";
import type { HostData } from "@/types/index";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
-44
View File
@@ -1,44 +0,0 @@
/**
* Frontend half of the proxmox plugin.
*
* Unlike ssh-terminal/docker, proxmox has no rail tab or standalone view —
* only a discovery dialog (opened imperatively from HostsPanel.tsx, not
* through a tab seam) and a host-editor settings tab. Registers the
* host-editor tab through the same registerHostEditorTab seam docker uses.
*
* HostProxmoxTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx
* rather than moving into this plugin: it takes the same {form, setField}
* pair every other host editor tab does, and those types (HostEditorForm,
* the setField signature) are core types HostEditor.tsx owns, the same
* reason ssh-terminal leaves session-manager.ts in core rather than copying
* it here.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled. As of this change neither is actually invoked anywhere yet --
* 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" for the frontend half. This file follows
* the same shape so it is ready the moment that loader exists. The dialog
* is reached today through a direct import in HostsPanel.tsx, not through
* this seam.
*/
export const id = "proxmox";
export const hostEditorTabId = "proxmox";
export async function register({ registerHostEditorTab, icons }) {
if (registerHostEditorTab) {
const { HostProxmoxTab } =
await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabProxmox",
icon: icons.Server,
component: HostProxmoxTab,
});
}
}
export async function unregister({ unregisterHostEditorTab }) {
unregisterHostEditorTab?.(hostEditorTabId);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState, type ComponentType } from "react";
import { Boxes, Server } from "lucide-react";
import {
useHosts,
useTranslation,
type HostEditorSectionProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
import type { SSHHostWithStatus } from "@/main-axios";
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
import { HostProxmoxTab } from "./HostProxmoxTab";
interface DiscoverRequest {
hostId?: number;
defaultCredentialId?: number | null;
defaultAuthType?: string;
}
type Opener = (request: DiscoverRequest) => void;
function ProxmoxHostSection({ form, setField }: HostEditorSectionProps) {
return (
<>
<HostProxmoxTab
form={form}
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
/>
{/* Other plugins add Proxmox-related settings here (stats). */}
<ComponentSlot slotId="proxmox.hostEditor" props={{ form, setField }} />
</>
);
}
export function activate(app: TermixApp): void {
// The dialog lives in the hosts panel; the menu item and host action open
// it through this, created per activation.
const openers = new Set<Opener>();
const openDiscover: Opener = (request) => {
for (const open of openers) open(request);
};
function DiscoverDialogHost({
hosts,
onHostsChanged,
}: {
hosts: SSHHostWithStatus[];
onHostsChanged: (hosts: SSHHostWithStatus[]) => void;
}) {
const [request, setRequest] = useState<DiscoverRequest | null>(null);
useEffect(() => {
openers.add(setRequest);
return () => {
openers.delete(setRequest);
};
}, []);
if (!request) return null;
return (
<ProxmoxDiscoverDialog
open
onClose={() => setRequest(null)}
hosts={hosts}
onHostsChanged={onHostsChanged}
preselectedHostId={request.hostId}
defaultCredentialId={request.defaultCredentialId ?? null}
defaultAuthType={request.defaultAuthType}
/>
);
}
function ImportMenuItem() {
const { t } = useTranslation();
const { hosts } = useHosts();
return (
<DropdownMenuItem
onClick={() => openDiscover({})}
disabled={!hosts.some((host) => host.enableProxmox)}
>
<Server className="size-3.5 mr-2" />
{t("hosts.proxmoxImportTitle")}
</DropdownMenuItem>
);
}
app.registerSlotContribution("hosts.panel", {
actionId: "proxmox.discoverDialog",
titleKey: "hosts.proxmoxImportTitle",
kind: "component",
component: DiscoverDialogHost as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSlotContribution("hosts.importMenu", {
actionId: "proxmox.import",
titleKey: "hosts.proxmoxImportTitle",
kind: "component",
component: ImportMenuItem as ComponentType<Record<string, unknown>>,
});
app.registerHostAction({
id: "proxmox-discover",
titleKey: "hosts.proxmoxDiscoverAction",
icon: Boxes,
kind: "open",
order: 90,
when: (host) => !!host.enableProxmox,
run: (host) => {
const config = host.proxmoxConfig as
| { defaultCredentialId?: number | null; defaultAuthType?: string }
| undefined;
openDiscover({
hostId: Number(host.id),
defaultCredentialId: config?.defaultCredentialId ?? null,
defaultAuthType: config?.defaultAuthType ?? undefined,
});
},
});
app.registerHostEditorSection({
id: "proxmox",
group: "ssh",
titleKey: "hosts.tabProxmox",
icon: Server,
order: 50,
component: ProxmoxHostSection,
});
}