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
@@ -0,0 +1,457 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "./web-endpoint-url";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import {
|
||||
MAX_WEB_ENDPOINTS,
|
||||
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
||||
type WebEndpoint,
|
||||
type WebUiConfig,
|
||||
} from "@/types/index";
|
||||
import {
|
||||
MAX_WEB_ENDPOINT_PORT,
|
||||
MIN_WEB_ENDPOINT_PORT,
|
||||
isWebEndpointPortValid,
|
||||
webEndpointErrorKey,
|
||||
webEndpointRowError,
|
||||
} from "./web-endpoint-validation";
|
||||
|
||||
/**
|
||||
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
|
||||
* deployment -- a first-class target for the direct+external path -- would
|
||||
* throw on "Add endpoint". Matches the guarded form already used in
|
||||
* MacrosPanel, PanePreview, KeybindingsDialog and AppShell.
|
||||
*/
|
||||
function endpointId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
// Only the tunnel-bind reasons render here; this preview sits inside the
|
||||
// tunnel-access block. The direct cookie refusal is enforced at open time in
|
||||
// WebEndpointTab / openWebEndpointExternally, where the target host is known.
|
||||
const BIND_HOST_REFUSAL_MESSAGES: Record<
|
||||
Exclude<WebEndpointRefusalReason, "direct-shares-session-cookie">,
|
||||
string
|
||||
> = {
|
||||
"loopback-bind-on-remote-backend": "hosts.webUiBindHostUnreachable",
|
||||
"shares-session-cookie-with-termix": "hosts.webUiBindHostSharesSessionCookie",
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the bind address reaches beyond the machine running the backend. A
|
||||
* forward bound there serves the target's web UI to anyone who can reach the
|
||||
* port, with no authentication in front of it, so the editor says so plainly
|
||||
* rather than leaving it to be discovered.
|
||||
*/
|
||||
function isExposedBindHost(bindHost: string | undefined): boolean {
|
||||
const value = (bindHost ?? "").trim();
|
||||
if (!value) return false;
|
||||
return !["127.0.0.1", "localhost", "::1", "[::1]"].includes(
|
||||
value.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function newEndpoint(label: string): WebEndpoint {
|
||||
return {
|
||||
id: endpointId(),
|
||||
label,
|
||||
scheme: "https",
|
||||
port: 443,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
};
|
||||
}
|
||||
|
||||
export function HostEditorWebUiSection({
|
||||
enableWebUi,
|
||||
webUiConfig,
|
||||
tunnelAvailable,
|
||||
setField,
|
||||
}: {
|
||||
enableWebUi: boolean;
|
||||
webUiConfig: WebUiConfig;
|
||||
tunnelAvailable: boolean;
|
||||
setField: (field: string, value: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = webUiConfig?.endpoints ?? [];
|
||||
|
||||
const commit = (next: WebEndpoint[]) =>
|
||||
setField("webUiConfig", { endpoints: next });
|
||||
|
||||
const update = (index: number, patch: Partial<WebEndpoint>) =>
|
||||
commit(
|
||||
endpoints.map((endpoint, i) =>
|
||||
i === index ? { ...endpoint, ...patch } : endpoint,
|
||||
),
|
||||
);
|
||||
|
||||
// Labels identify an endpoint in the sidebar picker, so a fresh row must not
|
||||
// silently collide with one already there.
|
||||
const uniqueLabel = (base: string) => {
|
||||
const taken = new Set(endpoints.map((e) => e.label.trim()));
|
||||
if (!taken.has(base)) return base;
|
||||
for (let n = 2; ; n += 1) {
|
||||
const candidate = `${base} ${n}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
};
|
||||
|
||||
const addEndpoint = () =>
|
||||
commit([
|
||||
...endpoints,
|
||||
newEndpoint(uniqueLabel(t("hosts.webUiNewEndpointLabel"))),
|
||||
]);
|
||||
|
||||
return (
|
||||
// Two cards, as the tunnels tab does: settings first, then the list it
|
||||
// governs, each with its own header.
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.webUiIntegration")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableWebUi")}
|
||||
description={t("hosts.enableWebUiDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={enableWebUi}
|
||||
onChange={(v: boolean) => setField("enableWebUi", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
||||
<p>{t("hosts.webUiRequirementsText")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.webUiEndpointsSection")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
action={
|
||||
enableWebUi ? (
|
||||
// Same header control, and the same classes, as the tunnels list.
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
disabled={endpoints.length >= MAX_WEB_ENDPOINTS}
|
||||
onClick={addEndpoint}
|
||||
>
|
||||
{t("hosts.webUiAddEndpoint")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{enableWebUi && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{endpoints.map((endpoint, index) => {
|
||||
const rowError = webEndpointRowError(endpoint);
|
||||
return (
|
||||
<div
|
||||
key={endpoint.id}
|
||||
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{t("hosts.webUiEndpointLabel", { number: index + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t("hosts.webUiRemoveEndpoint")}
|
||||
className="h-6 text-[10px] px-2 text-destructive"
|
||||
onClick={() =>
|
||||
commit(endpoints.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.webUiLabel")}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t("hosts.webUiLabel")}
|
||||
value={endpoint.label}
|
||||
onChange={(e) =>
|
||||
update(index, { label: e.target.value })
|
||||
}
|
||||
maxLength={MAX_WEB_ENDPOINT_LABEL_LENGTH}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{t(webEndpointErrorKey(rowError), { row: index + 1 })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.webUiAddress")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={endpoint.scheme}
|
||||
onValueChange={(value) =>
|
||||
update(index, {
|
||||
scheme: value as WebEndpoint["scheme"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("hosts.webUiScheme")}
|
||||
size="sm"
|
||||
className="h-7 w-24 text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="https" className="text-xs">
|
||||
https
|
||||
</SelectItem>
|
||||
<SelectItem value="http" className="text-xs">
|
||||
http
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
aria-label={t("hosts.webUiPort")}
|
||||
type="number"
|
||||
min={MIN_WEB_ENDPOINT_PORT}
|
||||
max={MAX_WEB_ENDPOINT_PORT}
|
||||
value={endpoint.port}
|
||||
onChange={(e) => {
|
||||
// Number("") is 0, which the normalizer rejects --
|
||||
// so clearing the field would otherwise write an
|
||||
// endpoint that is silently dropped on save.
|
||||
// Commit only a value the normalizer would keep;
|
||||
// anything else leaves the last good port.
|
||||
const parsed = Number(e.target.value);
|
||||
if (isWebEndpointPortValid(parsed)) {
|
||||
update(index, { port: parsed });
|
||||
}
|
||||
}}
|
||||
className="h-7 w-20 text-xs"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hosts.webUiPath")}
|
||||
value={endpoint.path ?? "/"}
|
||||
onChange={(e) =>
|
||||
update(index, { path: e.target.value })
|
||||
}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.webUiOpening")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={endpoint.access}
|
||||
onValueChange={(value) =>
|
||||
update(index, {
|
||||
access: value as WebEndpoint["access"],
|
||||
// Meaningless for a tunnel, whose host component
|
||||
// is loopback and already exempt.
|
||||
ignoreCert:
|
||||
value === "direct"
|
||||
? endpoint.ignoreCert
|
||||
: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("hosts.webUiAccess")}
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="direct" className="text-xs">
|
||||
{t("hosts.webUiAccessDirect")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="tunnel"
|
||||
disabled={!tunnelAvailable}
|
||||
>
|
||||
{t("hosts.webUiAccessTunnel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={endpoint.render}
|
||||
onValueChange={(value) =>
|
||||
update(index, {
|
||||
render: value as WebEndpoint["render"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("hosts.webUiRender")}
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value="external"
|
||||
className="text-xs"
|
||||
disabled={!isElectron()}
|
||||
>
|
||||
{t("hosts.webUiRenderExternal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="embedded" className="text-xs">
|
||||
{t("hosts.webUiRenderEmbedded")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{endpoint.access === "tunnel" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.bindHost")}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t("hosts.bindHost")}
|
||||
placeholder="127.0.0.1"
|
||||
value={endpoint.bindHost ?? ""}
|
||||
onChange={(e) =>
|
||||
update(index, { bindHost: e.target.value })
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] opacity-70">
|
||||
{t("hosts.webUiBindHostDesc")}
|
||||
</p>
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.webUiLocalPort")}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t("hosts.webUiLocalPort")}
|
||||
type="number"
|
||||
min={MIN_WEB_ENDPOINT_PORT}
|
||||
max={MAX_WEB_ENDPOINT_PORT}
|
||||
placeholder={t("hosts.webUiLocalPortAuto")}
|
||||
value={endpoint.localPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
update(index, { localPort: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (isWebEndpointPortValid(parsed)) {
|
||||
update(index, { localPort: parsed });
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] opacity-70">
|
||||
{t("hosts.webUiLocalPortDesc")}
|
||||
</p>
|
||||
{isExposedBindHost(endpoint.bindHost) && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{t("hosts.webUiBindHostExposed")}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
// Said while configuring, not only when the tab fails
|
||||
// to load: a loopback bind on a backend that is not
|
||||
// this machine opens fine and then leaves the browser
|
||||
// with nothing to connect to, and a tunnel reached at
|
||||
// Termix's own hostname would hand the tunnelled
|
||||
// service this session.
|
||||
const refusal = webEndpointRefusalReason(
|
||||
endpoint,
|
||||
isElectron(),
|
||||
undefined,
|
||||
);
|
||||
if (!refusal) return null;
|
||||
// Unreachable from this tunnel-only block; narrows
|
||||
// the union to the reasons this preview renders.
|
||||
if (refusal === "direct-shares-session-cookie") {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{t(BIND_HOST_REFUSAL_MESSAGES[refusal])}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.render === "embedded" && (
|
||||
<p className="text-[11px] opacity-70">
|
||||
{t("hosts.webUiRenderEmbeddedDesc")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{endpoint.access === "direct" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={endpoint.ignoreCert === true}
|
||||
onCheckedChange={(value) =>
|
||||
update(index, { ignoreCert: value === true })
|
||||
}
|
||||
/>
|
||||
{t("hosts.webUiIgnoreCert")}
|
||||
</label>
|
||||
<p className="text-[11px] opacity-70">
|
||||
{t("hosts.webUiIgnoreCertDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!tunnelAvailable && endpoints.length > 0 && (
|
||||
// Shown whenever tunnelling is unavailable, not only once a row
|
||||
// already asks for it: the Tunnel option renders disabled, and
|
||||
// a disabled control with no stated reason reads as the
|
||||
// dropdown being broken.
|
||||
<p className="text-[11px] opacity-70">
|
||||
{t("hosts.webUiAccessTunnelUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
|
||||
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Owns the async connection-origin resolution for the Web UI tab's tunnel
|
||||
* gate, then hands the resolved boolean to the presentational section.
|
||||
*
|
||||
* tunnelAvailable = enableSsh && originIsLocal. Deliberately NOT gated on
|
||||
* isElectron(): the forward binds wherever the backend runs, exactly as the
|
||||
* server tunnels feature does, and a web deployment reaches it at the host
|
||||
* serving Termix provided the endpoint opted out of a loopback bind. Gating
|
||||
* this on the desktop would be stricter than the tunnels feature it mirrors.
|
||||
*
|
||||
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
|
||||
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
|
||||
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
|
||||
*
|
||||
* The origin state lives here rather than in HostEditor.tsx, which is already
|
||||
* very long; this follows HostEditorGeneralTab's precedent of taking
|
||||
* `protocols` as a prop and gating its own connection-origin control.
|
||||
*
|
||||
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
|
||||
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
|
||||
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
|
||||
* all. That is the accepted behaviour, not an oversight.
|
||||
*/
|
||||
export function HostWebUiTab({
|
||||
form,
|
||||
setField,
|
||||
protocols,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
protocols: HostProtocols;
|
||||
}) {
|
||||
const [originIsLocal, setOriginIsLocal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
}).then((origin) => {
|
||||
if (!cancelled) setOriginIsLocal(origin === "local");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.connectionOrigin]);
|
||||
|
||||
return (
|
||||
<HostEditorWebUiSection
|
||||
enableWebUi={form.enableWebUi}
|
||||
webUiConfig={form.webUiConfig}
|
||||
tunnelAvailable={protocols.enableSsh && originIsLocal}
|
||||
setField={setField as (field: string, value: unknown) => void}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Globe, RotateCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Frontend half of the web-endpoint plugin.
|
||||
*
|
||||
* Registers the endpoint viewer tab through the existing registerTabComponent
|
||||
* seam the same way network-topology's frontend does.
|
||||
*
|
||||
* HostEditorWebUiSection stays in src/ui/sidebar/ rather than moving here, and
|
||||
* is not registered through registerHostEditorTab: it is a section inside the
|
||||
* host editor's existing General tab, not a tab of its own, and it takes the
|
||||
* same {form, setField} pair every other host editor field does -- those types
|
||||
* (HostEditorForm, the setField signature) are core types HostEditor.tsx owns,
|
||||
* the same reason docker leaves HostDockerTab in core rather than copying it
|
||||
* into plugins/docker.
|
||||
*
|
||||
* register() is called when the plugin is enabled, unregister() when it is
|
||||
* disabled. As of this change neither is actually invoked anywhere yet -- see
|
||||
* src/ui/shell/pluginLoader.ts and
|
||||
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||
* "there is no plugin loader yet" for the frontend half. Until one exists the
|
||||
* tab is rendered directly by tabUtils.tsx and merely gated on the plugin's
|
||||
* enabled state through BUILT_IN_TABS_BY_PLUGIN. This file follows the same
|
||||
* shape every other plugin's frontend entry does so it is ready the moment a
|
||||
* real loader lands.
|
||||
*/
|
||||
|
||||
export const id = "web-endpoint";
|
||||
export const tabId = "web-endpoint";
|
||||
|
||||
export async function register({ registerTabComponent }) {
|
||||
registerTabComponent?.(tabId, () =>
|
||||
import("./WebEndpointTab.tsx").then((m) => ({
|
||||
default: m.WebEndpointTab,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unregister({ unregisterTabComponent }) {
|
||||
unregisterTabComponent?.(tabId);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import i18next from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
HostEditorSectionProps,
|
||||
PluginHostRecord,
|
||||
ShellApi,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types";
|
||||
import { WebEndpointTab } from "./WebEndpointTab";
|
||||
import { HostWebUiTab } from "./HostWebUiTab";
|
||||
import { openWebEndpointExternally } from "./web-endpoint-api";
|
||||
|
||||
const TAB_TYPE = "web-endpoint";
|
||||
|
||||
function endpointsOf(host: PluginHostRecord): WebEndpoint[] {
|
||||
if (!host.enableWebUi) return [];
|
||||
const config = host.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
||||
return config?.endpoints ?? [];
|
||||
}
|
||||
|
||||
function openEndpoint(
|
||||
host: PluginHostRecord,
|
||||
endpoint: WebEndpoint,
|
||||
shell: ShellApi,
|
||||
): void {
|
||||
if (endpoint.render === "external") {
|
||||
// No tab at all: the real browser opens it. On the desktop the main
|
||||
// process routes it to shell.openExternal.
|
||||
openWebEndpointExternally(
|
||||
host as unknown as { id: string; ip: string },
|
||||
endpoint,
|
||||
).catch((error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: i18next.t("web-endpoint:hosts.webUiOpenFailed"),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
shell.openTab(host, TAB_TYPE, {
|
||||
label: endpoint.label,
|
||||
data: { endpointId: endpoint.id },
|
||||
});
|
||||
}
|
||||
|
||||
function EndpointTab({ host, tab }: TabProps) {
|
||||
// Resolved against the host on every render, so an endpoint deleted while
|
||||
// its tab is open shows a plain message instead of throwing.
|
||||
const endpointId = tab.data?.endpointId;
|
||||
return (
|
||||
<WebEndpointTab
|
||||
host={host as unknown as Host}
|
||||
endpointId={typeof endpointId === "string" ? endpointId : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
|
||||
return (
|
||||
<HostWebUiTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostWebUiTab>[0]["setField"]}
|
||||
protocols={
|
||||
protocols as unknown as Parameters<typeof HostWebUiTab>[0]["protocols"]
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerTab(TAB_TYPE, EndpointTab, {
|
||||
icon: Globe,
|
||||
titleKey: "hosts.tabWebUi",
|
||||
requiresHost: true,
|
||||
noHostMessageKey: "webEndpoint.noHostSelected",
|
||||
// A restored tab could never hold a valid URL: the backend closes an idle
|
||||
// tunnel after ten minutes and binds a fresh port on the next open. So
|
||||
// these tabs are session-only and never saved.
|
||||
persistent: false,
|
||||
inLayouts: false,
|
||||
});
|
||||
|
||||
// One entry per host, not one per endpoint: with several it opens a picker.
|
||||
// Gated on enableWebUi alone, since a direct endpoint needs no SSH.
|
||||
app.registerHostAction({
|
||||
id: TAB_TYPE,
|
||||
titleKey: "hosts.tabWebUi",
|
||||
icon: Globe,
|
||||
kind: "open",
|
||||
order: 80,
|
||||
when: (host) => endpointsOf(host).length > 0,
|
||||
label: (host) => {
|
||||
const endpoints = endpointsOf(host);
|
||||
return endpoints.length === 1 ? endpoints[0].label : undefined;
|
||||
},
|
||||
items: (host) =>
|
||||
endpointsOf(host).map((endpoint) => ({
|
||||
id: endpoint.id,
|
||||
label: endpoint.label,
|
||||
run: (target, shell) => openEndpoint(target, endpoint, shell),
|
||||
})),
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "web-ui",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabWebUi",
|
||||
icon: Globe,
|
||||
order: 40,
|
||||
component: WebUiSection,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user