mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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,37 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { FolderOpen, Network } from "lucide-react";
|
||||
import { tabTypeForActivity } from "@/shell/tab-registry";
|
||||
|
||||
export interface ActivityTarget {
|
||||
icon: ComponentType<{ className?: string }> | undefined;
|
||||
/** Tab type the entry reopens. */
|
||||
tab: string;
|
||||
labelKey: string | undefined;
|
||||
}
|
||||
|
||||
/** Recent-activity types core itself records. */
|
||||
const CORE_ACTIVITY: Record<string, ActivityTarget> = {
|
||||
file_manager: {
|
||||
icon: FolderOpen,
|
||||
tab: "files",
|
||||
labelKey: "dashboard.activityFileManager",
|
||||
},
|
||||
tunnel: {
|
||||
icon: Network,
|
||||
tab: "tunnel",
|
||||
labelKey: "dashboard.activityTunnel",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* What a recent-activity entry opens and how it is labelled. Plugin tabs
|
||||
* claim their activity types through the registry, so an entry whose plugin
|
||||
* is gone simply has no target.
|
||||
*/
|
||||
export function activityTarget(type: string): ActivityTarget | undefined {
|
||||
const core = CORE_ACTIVITY[type];
|
||||
if (core) return core;
|
||||
const def = tabTypeForActivity(type);
|
||||
if (!def) return undefined;
|
||||
return { icon: def.icon, tab: def.id, labelKey: def.titleKey };
|
||||
}
|
||||
@@ -1,40 +1,36 @@
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
|
||||
type ConnectionTabType = "terminal" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
function isConnectionTabType(type: TabType): type is ConnectionTabType {
|
||||
return (
|
||||
type === "terminal" || type === "rdp" || type === "vnc" || type === "telnet"
|
||||
);
|
||||
}
|
||||
|
||||
function isConnectionEnabled(host: Host, type: ConnectionTabType): boolean {
|
||||
switch (type) {
|
||||
case "terminal":
|
||||
return host.enableSsh;
|
||||
case "rdp":
|
||||
return host.enableRdp;
|
||||
case "vnc":
|
||||
return host.enableVnc;
|
||||
case "telnet":
|
||||
return host.enableTelnet;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultConnectionTab(host: Host): ConnectionTabType {
|
||||
if (host.enableSsh) return "terminal";
|
||||
if (host.enableRdp) return "rdp";
|
||||
if (host.enableVnc) return "vnc";
|
||||
if (host.enableTelnet) return "telnet";
|
||||
return "terminal";
|
||||
/**
|
||||
* Which tab a click on a host opens. The ways to connect (SSH terminal, RDP,
|
||||
* VNC, Telnet) are host actions that plugins register, so the answer is the
|
||||
* highest-priority connect action the host allows.
|
||||
*/
|
||||
export function getDefaultConnectionTab(host: Host): TabType {
|
||||
return defaultConnectAction(listHostActions(), host)?.tabType ?? "terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred tab if the host allows it. A connect tab type the host has
|
||||
* switched off falls back to the host's default; any other type is taken as
|
||||
* asked.
|
||||
*/
|
||||
export function resolveHostTabType(
|
||||
host: Host,
|
||||
preferredType?: TabType,
|
||||
): TabType {
|
||||
if (!preferredType) return getDefaultConnectionTab(host);
|
||||
if (!isConnectionTabType(preferredType)) return preferredType;
|
||||
if (isConnectionEnabled(host, preferredType)) return preferredType;
|
||||
return getDefaultConnectionTab(host);
|
||||
const all = listHostActions();
|
||||
const isConnectType = all.some(
|
||||
(action) => action.kind === "connect" && action.tabType === preferredType,
|
||||
);
|
||||
if (!isConnectType) return preferredType;
|
||||
const allowed = hostActionsFor(all, host, "connect").some(
|
||||
(action) => action.tabType === preferredType,
|
||||
);
|
||||
return allowed ? preferredType : getDefaultConnectionTab(host);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
/** The shell's Host in the SSHHost shape the connection surfaces take. */
|
||||
export function hostToSSHHost(h: Host): SSHHost {
|
||||
return {
|
||||
id: parseInt(h.id, 10),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
hasPassword: h.hasPassword,
|
||||
key: h.key,
|
||||
keyPassword: h.keyPassword,
|
||||
hasKey: h.hasKey,
|
||||
hasKeyPassword: h.hasKeyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
|
||||
terminalConfig: h.terminalConfig,
|
||||
hasSudoPassword: h.hasSudoPassword,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
|
||||
enableAiAssistant: h.enableAiAssistant ?? false,
|
||||
dockerConfig: h.dockerConfig ?? null,
|
||||
enableWebUi: h.enableWebUi ?? false,
|
||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
showDockerInSidebar: true,
|
||||
showServerStatsInSidebar: true,
|
||||
defaultPath: h.defaultPath ?? "",
|
||||
tunnelConnections: [],
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
isShared: h.isShared ?? false,
|
||||
// Carries the host's identity to a delegated backend. Without it the
|
||||
// remote side resolves our local row id against its own table.
|
||||
syncId: h.syncId ?? null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as unknown as SSHHost;
|
||||
}
|
||||
@@ -2,12 +2,20 @@
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
|
||||
* it never has to know where the strings live. Today they live in core
|
||||
* en.json under `plugins.<pluginId>.`, because the frontend does not load
|
||||
* plugin locale bundles yet. When it does, that subtree moves into
|
||||
* plugins/<id>/locales/en.json as the plugin's own namespace and only this
|
||||
* function changes; no manifest is touched.
|
||||
* it never has to know where the strings live. They live in the plugin's own
|
||||
* locales/en.json, loaded into an i18next namespace named after the plugin,
|
||||
* so the qualified key is "<pluginId>:<key>". A key that already names a
|
||||
* namespace is left alone. Missing keys fall back to core strings.
|
||||
*/
|
||||
export function pluginKey(pluginId: string, key: string): string {
|
||||
return `plugins.${pluginId}.${key}`;
|
||||
export function pluginKey(pluginId: string, key: string): string;
|
||||
export function pluginKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined;
|
||||
export function pluginKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined {
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* A keyed, reactive list of contributions.
|
||||
*
|
||||
* Plugins register and unregister at runtime, so anything the shell renders
|
||||
* from a registry has to re-render when it changes. The snapshot array is
|
||||
* cached until the next change because useSyncExternalStore compares by
|
||||
* identity and loops forever on a fresh array.
|
||||
*/
|
||||
export interface Registry<T extends { id: string }> {
|
||||
/** Adds or replaces by id. The disposer only removes this exact entry. */
|
||||
register: (item: T) => () => void;
|
||||
unregister: (id: string) => void;
|
||||
get: (id: string) => T | undefined;
|
||||
list: () => T[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** React hook over list(). */
|
||||
useList: () => T[];
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function createRegistry<T extends { id: string }>(
|
||||
sort?: (a: T, b: T) => number,
|
||||
): Registry<T> {
|
||||
const items = new Map<string, T>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: T[] | null = null;
|
||||
|
||||
const emit = () => {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
const list = () => {
|
||||
if (!snapshot) {
|
||||
snapshot = [...items.values()];
|
||||
if (sort) snapshot.sort(sort);
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
register(item) {
|
||||
items.set(item.id, item);
|
||||
emit();
|
||||
return () => {
|
||||
if (items.get(item.id) === item) {
|
||||
items.delete(item.id);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
},
|
||||
unregister(id) {
|
||||
if (items.delete(id)) emit();
|
||||
},
|
||||
get: (id) => items.get(id),
|
||||
list,
|
||||
subscribe,
|
||||
useList: () => useSyncExternalStore(subscribe, list, list),
|
||||
reset() {
|
||||
items.clear();
|
||||
emit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Orders by `order`, then id, so plugin contributions sort stably. */
|
||||
export function byOrderThenId<T extends { id: string; order?: number }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
return (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id);
|
||||
}
|
||||
@@ -36,12 +36,6 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
description: "Feed of recent connection events",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "network_graph",
|
||||
label: "Network Graph",
|
||||
description: "Visual map of host network topology",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "service_links",
|
||||
label: "Service Links",
|
||||
|
||||
Reference in new issue
Block a user