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,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useTranslation,
|
||||
type SshAuthEditorProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import { getTailscaleDevices } from "./tailscale-api";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
os: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
function tailnetAddress(device: Device): string {
|
||||
return (
|
||||
device.addresses.find((address) => address.startsWith("100.")) ??
|
||||
device.addresses[0] ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host editor's "tailscale" auth method: pick a tailnet device and its
|
||||
* address fills the host's IP.
|
||||
*/
|
||||
export function TailscaleAuthEditor({ form, setField }: SshAuthEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
getTailscaleDevices()
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setDevices(result?.devices ?? []);
|
||||
setHasApiKey(result?.hasApiKey ?? false);
|
||||
setFailed(!!result?.error);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ip = String(form.ip ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.tailscaleDeviceSelect")}
|
||||
</label>
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/tailscale"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.tailscaleDocsLink")}
|
||||
</a>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleLoadingDevices")}
|
||||
</p>
|
||||
) : failed ? (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("hosts.tailscaleDeviceLoadFailed")}
|
||||
</p>
|
||||
) : !hasApiKey ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleNoApiKey")}
|
||||
</p>
|
||||
) : devices.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleNoDevices")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Select2
|
||||
className="w-full border border-border bg-background text-foreground text-xs px-2 py-1.5 focus:outline-none focus:border-accent-brand/50"
|
||||
value={
|
||||
devices.find((device) => device.addresses.includes(ip))?.id ?? ""
|
||||
}
|
||||
onChange={(event) => {
|
||||
const device = devices.find(
|
||||
(item) => item.id === event.target.value,
|
||||
);
|
||||
if (device) setField("ip", tailnetAddress(device));
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("hosts.tailscaleDeviceSelectPlaceholder")}
|
||||
</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.hostname} ({tailnetAddress(device)})
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleDeviceAutoFill")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Shield, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TailscaleCheckDialogProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { TerminalOverlayProps } from "@/features/terminal/terminal-slots";
|
||||
import { TailscaleCheckDialog } from "./TailscaleCheckDialog";
|
||||
|
||||
/** How long Tailscale may hold a connection open waiting on approval. */
|
||||
const CHECK_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
interface CheckState {
|
||||
authUrl: string;
|
||||
message?: string;
|
||||
stage: "prompt" | "waiting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailscale SSH can hold a connection open until the user approves it in a
|
||||
* browser. The terminal's server messages say when, and this pauses the
|
||||
* connect timeout and shows the approval link meanwhile.
|
||||
*/
|
||||
export function TailscaleCheckOverlay({
|
||||
subscribe,
|
||||
holdConnectTimeout,
|
||||
fail,
|
||||
disconnect,
|
||||
backgroundColor,
|
||||
}: TerminalOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const [check, setCheck] = useState<CheckState | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heldRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const release = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
setCheck(null);
|
||||
};
|
||||
|
||||
const unsubscribe = subscribe((message) => {
|
||||
if (message.type === "tailscale_check_required") {
|
||||
if (!heldRef.current) {
|
||||
heldRef.current = true;
|
||||
holdConnectTimeout(true);
|
||||
}
|
||||
setCheck({
|
||||
authUrl: typeof message.url === "string" ? message.url : "",
|
||||
message:
|
||||
typeof message.message === "string" ? message.message : undefined,
|
||||
stage: "prompt",
|
||||
});
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
release();
|
||||
fail(t("terminal.tailscaleCheckTimeout"));
|
||||
}, CHECK_TIMEOUT_MS);
|
||||
} else if (
|
||||
message.type === "tailscale_check_completed" ||
|
||||
message.type === "session_closed"
|
||||
) {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
release();
|
||||
};
|
||||
}, [subscribe, holdConnectTimeout, fail, t]);
|
||||
|
||||
if (!check) return null;
|
||||
|
||||
return (
|
||||
<TailscaleCheckDialog
|
||||
isOpen
|
||||
authUrl={check.authUrl}
|
||||
message={check.message}
|
||||
stage={check.stage}
|
||||
onCancel={() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
setCheck(null);
|
||||
disconnect();
|
||||
}}
|
||||
onOpenUrl={() => {
|
||||
window.open(check.authUrl, "_blank");
|
||||
setCheck((current) =>
|
||||
current ? { ...current, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Loader2, RefreshCw, Terminal } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getTailscaleDevices } from "./tailscale-api";
|
||||
import { createQuickConnectHost } from "../../../../src/ui/sidebar/quick-connect-host";
|
||||
import { createQuickConnectHost } from "@/sidebar/quick-connect-host";
|
||||
|
||||
interface TailscaleDevice {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The tailnet status shown on the Tailscale settings page.
|
||||
*
|
||||
* Confirms the saved key actually works, which is the question anyone has just
|
||||
* after entering one. Connecting to a device stays in the Tailscale rail panel;
|
||||
* a settings page is for configuring, not for operating.
|
||||
*
|
||||
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
|
||||
* bundle register it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import type { SettingsComponentProps } from "@termix/plugin-sdk/frontend";
|
||||
import { getTailscaleDevices } from "./tailscale-api";
|
||||
|
||||
type Status =
|
||||
| { kind: "loading" }
|
||||
| { kind: "no-key" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ok"; count: number };
|
||||
|
||||
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<Status>({ kind: "loading" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!running) {
|
||||
setStatus({ kind: "no-key" });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: "loading" });
|
||||
try {
|
||||
const result = await getTailscaleDevices();
|
||||
if (!result.hasApiKey) {
|
||||
setStatus({ kind: "no-key" });
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
setStatus({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
|
||||
} catch {
|
||||
setStatus({ kind: "error" });
|
||||
}
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const label = (key: string) => t(key);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label("settings.devices.label")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{status.kind === "loading" && label("settings.devices.checking")}
|
||||
{status.kind === "no-key" && label("settings.devices.noKey")}
|
||||
{status.kind === "error" && label("settings.devices.error")}
|
||||
{status.kind === "ok" &&
|
||||
t("settings.devices.count", {
|
||||
count: status.count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs shrink-0"
|
||||
disabled={!running || status.kind === "loading"}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{status.kind === "loading" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Frontend half of the tailscale plugin.
|
||||
*
|
||||
* TailscaleDevicesPanel is a left-sidebar railView panel (opened from the
|
||||
* rail, not a Tab), the same shape as FleetsPanel/QuickConnectPanel. 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.
|
||||
* TailscaleDevicesPanel is reached today through a direct relative import in
|
||||
* AppShell.tsx (../../plugins/tailscale/frontend/TailscaleDevicesPanel), 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".
|
||||
*
|
||||
* TailscaleCheckDialog is not registered here at all: it is rendered
|
||||
* directly by src/ui/features/terminal/Terminal.tsx (owned by ssh-terminal),
|
||||
* driven by raw WS message types on the terminal socket. There is no
|
||||
* dialog-registration extension point for that today, so this plugin ships
|
||||
* the component as a plain module ssh-terminal imports by path.
|
||||
*/
|
||||
|
||||
export const id = "tailscale";
|
||||
export const railViewId = "tailscale";
|
||||
|
||||
export async function register({ registerRailItem, icons }) {
|
||||
registerRailItem?.({
|
||||
id: railViewId,
|
||||
icon: icons.Radar,
|
||||
labelKey: "nav.tailscale",
|
||||
separatorAfter: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregister({ unregisterRailItem }) {
|
||||
unregisterRailItem?.(railViewId);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Radar } from "lucide-react";
|
||||
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { TailscaleDevicesPanel } from "./TailscaleDevicesPanel";
|
||||
import { TailscaleDevicesStatus } from "./TailscaleDevicesStatus";
|
||||
import { TailscaleAuthEditor } from "./TailscaleAuthEditor";
|
||||
import { TailscaleCheckOverlay } from "./TailscaleCheckOverlay";
|
||||
|
||||
function Panel({ shell }: PanelProps) {
|
||||
return (
|
||||
<TailscaleDevicesPanel
|
||||
onConnect={(host: Host, type) =>
|
||||
shell.openTab(
|
||||
host as unknown as Parameters<typeof shell.openTab>[0],
|
||||
type,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerRailItem({
|
||||
id: "tailscale",
|
||||
icon: Radar,
|
||||
titleKey: "nav.tailscale",
|
||||
after: "macros",
|
||||
order: 20,
|
||||
});
|
||||
app.registerPanel("tailscale", Panel);
|
||||
|
||||
app.registerSettingsComponent("devices", TailscaleDevicesStatus);
|
||||
|
||||
// "tailscale" as a host's SSH auth method: pick a device from the tailnet.
|
||||
app.registerSshAuthEditor({
|
||||
authType: "tailscale",
|
||||
titleKey: "nav.tailscale",
|
||||
hintKey: "hosts.tailscaleUsernameHint",
|
||||
component: TailscaleAuthEditor,
|
||||
});
|
||||
|
||||
// Approval prompts while Tailscale SSH holds a connection open.
|
||||
app.registerSlotContribution("terminal.overlay", {
|
||||
actionId: "tailscale.check",
|
||||
titleKey: "terminal.tailscaleCheckRequired",
|
||||
kind: "component",
|
||||
component: TailscaleCheckOverlay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user