mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user