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

+221 -326
View File
@@ -26,14 +26,15 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { resetPermissionsCache } from "@/hooks/use-permissions";
import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { AppRail, type RailView } from "@/sidebar/AppRail";
import {
isCoreRailView,
railItemLabel,
PROMOTABLE_IDS,
RIGHT_DOCKABLE_IDS,
promotableIds,
rightDockableIds,
useRailItems,
} from "@/sidebar/rail-items";
import { MultiPanelHint } from "@/sidebar/MultiPanelHint";
import { OnboardingDialog } from "@/onboarding/OnboardingDialog";
@@ -91,33 +92,6 @@ const SnippetsPanel = lazy(() =>
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
const FleetsPanel = lazy(() =>
import("../../plugins/fleets/src/frontend/FleetsPanel").then((m) => ({
default: m.FleetsPanel,
})),
);
const TailscaleDevicesPanel = lazy(() =>
import("../../plugins/tailscale/src/frontend/TailscaleDevicesPanel").then(
(m) => ({ default: m.TailscaleDevicesPanel }),
),
);
const WorkspacesPanel = lazy(() =>
import("../../plugins/workspaces/src/frontend/WorkspacesPanel").then((m) => ({
default: m.WorkspacesPanel,
})),
);
const AutomationsPanel = lazy(() =>
import("../../plugins/automations/src/frontend/AutomationsPanel").then(
(m) => ({
default: m.AutomationsPanel,
}),
),
);
const AiPanel = lazy(() =>
import("@/features/ai/AiPanel").then((m) => ({
default: m.AiPanel,
})),
);
const HistoryPanel = lazy(() =>
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
);
@@ -168,7 +142,6 @@ import type {
ThemeId,
FontSizeId,
SerialConfig,
Workspace,
WorkspacePayload,
} from "@/types/ui-types";
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
@@ -193,15 +166,11 @@ import {
type OpenTabRecord,
} from "@/main-axios";
import {
listWorkspaces,
applyWorkspaceServer,
saveLastSessionWorkspace,
} from "../../plugins/workspaces/src/frontend/workspaces-api";
import {
buildWorkspacePayload as buildWorkspacePayloadUtil,
buildLayoutPayload,
remapSlotIds,
resolveWorkspaceTabTarget,
} from "../../plugins/workspaces/src/frontend/workspaceUtils";
resolveLayoutTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
@@ -221,36 +190,31 @@ import {
serializeSplitTabs,
type PersistedSplitTab,
} from "@/shell/splitTabUtils";
import { isTabTypeAvailable, refreshPluginState } from "@/shell/pluginLoader";
import {
canRestoreTabType,
getTabType,
isPersistentTabType,
isSessionTabType,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
notifyShellReady,
notifyTabsChanged,
resetShellBridge,
setShellCallbacks,
setShellHosts,
setShellLayoutProvider,
} from "@/plugin-host/shell-bridge";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
export { buildHostTree } from "@/sidebar/build-host-tree";
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
// ─── AppShell ────────────────────────────────────────────────────────────────
/**
* Tab types whose open/close is mirrored to the backend's open-tabs record, so
* they survive a reload or reopen on another device.
*
* "web-endpoint" is deliberately ABSENT. The backend closes an idle tunnel
* after ten minutes and re-binds a fresh kernel-assigned port on the next
* open, so a restored web-endpoint tab could never hold a valid URL -- and the
* endpoint may have been edited or deleted meanwhile besides. Restoring one
* would mean re-opening the tunnel on restore, which is a feature, not
* symmetry. A web-endpoint tab is session-only and simply closes on reload,
* the same as "local-terminal" already does.
*/
export const PERSISTENT_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"files",
"docker",
"host-metrics",
"tunnel",
];
export function AppShell({
username,
onLogout,
@@ -260,8 +224,10 @@ export function AppShell({
}) {
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
const { globallyEnabled: aiGloballyEnabled, loaded: aiStatusLoaded } =
useAiAvailability();
// Re-render when plugins add or remove rail items, panels or tabs.
useRailItems();
const registeredPanels = usePanels();
const { settled: pluginsSettled } = usePluginStore();
const [tabs, setTabs] = useState<Tab[]>([
{
id: "dashboard",
@@ -309,6 +275,8 @@ export function AppShell({
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
const [hostsLoading, setHostsLoading] = useState(true);
const [allHosts, setAllHosts] = useState<Host[]>([]);
const allHostsRef = useRef(allHosts);
allHostsRef.current = allHosts;
const [isAdmin, setIsAdmin] = useState(false);
// The standalone desktop backend still owns system settings such as the
// Tailscale API key, even though it has only one implicit user.
@@ -316,7 +284,6 @@ export function AppShell({
const [userId, setUserId] = useState<string | null>(null);
const [showDonationModal, setShowDonationModal] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [onboardingAiEnabled, setOnboardingAiEnabled] = useState(false);
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
OpenTabRecord[]
>([]);
@@ -329,18 +296,12 @@ export function AppShell({
uiPrefs.preferences.onboarding.completedVersion < UI_ONBOARDING_VERSION;
/**
* Both onboarding entry points resolve the same context first: whether an
* admin has enabled the AI assistant. The AI step is skipped entirely when
* it is off, so the answer has to be in before the dialog opens.
* Both onboarding entry points wait for plugins first, since plugins add
* steps of their own (the AI step, feature cards).
*/
const loadOnboardingContext = useCallback(async () => {
try {
const { getAiStatus } = await import("@/api/ai-api");
const aiStatus = await getAiStatus();
setOnboardingAiEnabled(aiStatus.globallyEnabled);
} catch {
setOnboardingAiEnabled(false);
}
const { settledPromise } = await import("@/plugin-host/plugin-store");
await settledPromise();
}, []);
useEffect(() => {
@@ -382,7 +343,7 @@ export function AppShell({
// stay visible while the left sidebar is used for something else.
const [rightRailView, setRightRailView] = useState<RailView | null>(() => {
const saved = localStorage.getItem("termix_rightRailView");
return saved && RIGHT_DOCKABLE_IDS.includes(saved)
return saved && rightDockableIds().includes(saved)
? (saved as RailView)
: null;
});
@@ -463,10 +424,15 @@ export function AppShell({
.catch(() => setIsAdmin(false));
}, []);
// Plugin state gates which tab types can be opened, so it has to land before
// a saved session is restored. A failure leaves everything enabled.
// Plugin frontends register the tabs, panels and actions that a saved
// session may need, so they load before tabs are restored. On logout every
// plugin is deactivated, since the next user may see different ones.
useEffect(() => {
refreshPluginState().catch(() => {});
startPluginRuntime().catch(() => {});
return () => {
void stopPluginRuntime();
resetShellBridge();
};
}, []);
const handleDismissDonationModal = useCallback(() => {
@@ -1266,7 +1232,7 @@ export function AppShell({
}, [allHosts]);
function buildWorkspacePayload(): WorkspacePayload {
return buildWorkspacePayloadUtil({
return buildLayoutPayload({
tabs,
activeTabId,
splitMode,
@@ -1284,7 +1250,10 @@ export function AppShell({
});
}
async function applyWorkspace(workspace: Workspace) {
async function applyLayout(
payload: WorkspacePayload,
name: string,
): Promise<{ skipped: string[] }> {
// Tear down the current arrangement the same way an individual tab close does.
for (const tab of [...tabsRef.current]) {
doCloseTab(tab.id);
@@ -1293,8 +1262,8 @@ export function AppShell({
const slotIdToNewTabId = new Map<string, string>();
const skippedTabs: string[] = [];
for (const snapshot of workspace.payload.tabs) {
const target = resolveWorkspaceTabTarget(snapshot, allHosts);
for (const snapshot of payload.tabs) {
const target = resolveLayoutTabTarget(snapshot, allHostsRef.current);
if (target.kind === "skip") {
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
@@ -1312,7 +1281,7 @@ export function AppShell({
snapshot.type,
undefined,
target.host,
snapshot.fleetId,
snapshotData(snapshot),
);
slotIdToNewTabId.set(snapshot.slotId, snapshot.type);
continue;
@@ -1330,27 +1299,21 @@ export function AppShell({
}
}
const restoredPaneIds = remapSlotIds(
workspace.payload.paneTabIds,
slotIdToNewTabId,
);
const restoredPaneIds = remapSlotIds(payload.paneTabIds, slotIdToNewTabId);
let restoredSplitTabId: string | null = null;
if (
workspace.payload.splitMode !== "none" &&
restoredPaneIds.some(Boolean)
) {
if (payload.splitMode !== "none" && restoredPaneIds.some(Boolean)) {
const instanceId = crypto.randomUUID();
restoredSplitTabId = `split-${instanceId}`;
const splitTab: Tab = {
id: restoredSplitTabId,
instanceId,
type: "split-screen",
label: workspace.name,
label: name,
openedAt: Date.now(),
splitConfig: createSplitConfig(
workspace.payload.splitMode,
payload.splitMode,
restoredPaneIds,
workspace.payload,
payload,
),
};
setTabs((prev) =>
@@ -1358,13 +1321,13 @@ export function AppShell({
);
}
const activeId = workspace.payload.activeSlotId
? (slotIdToNewTabId.get(workspace.payload.activeSlotId) ?? "dashboard")
const activeId = payload.activeSlotId
? (slotIdToNewTabId.get(payload.activeSlotId) ?? "dashboard")
: "dashboard";
setActiveTabId(restoredSplitTabId ?? activeId);
// Older payloads predate the sidebar field, so leave the docks alone then.
const sidebar = workspace.payload.sidebar;
const sidebar = payload.sidebar;
if (sidebar) {
if (sidebar.left.view) setRailView(sidebar.left.view as RailView);
setSidebarOpen(sidebar.left.open);
@@ -1372,30 +1335,22 @@ export function AppShell({
const right = sidebar.right.open ? sidebar.right.view : null;
setRightRailView(
right && RIGHT_DOCKABLE_IDS.includes(right)
right && rightDockableIds().includes(right)
? (right as RailView)
: null,
);
if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width);
}
if (skippedTabs.length > 0) {
toast.warning(
t("newUi.sidebar.workspaces.tabsSkipped", {
count: skippedTabs.length,
names: skippedTabs.join(", "),
}),
);
}
applyWorkspaceServer(workspace.id).catch(() => {});
return { skipped: skippedTabs };
}
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
const tabRestoreAttemptedRef = useRef(false);
useEffect(() => {
if (!hostsLoaded || !userPrefsLoaded) return;
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return;
if (tabRestoreAttemptedRef.current) return;
tabRestoreAttemptedRef.current = true;
@@ -1416,7 +1371,7 @@ export function AppShell({
if (userPrefs.reopenTabsOnLogin) {
const hasPersistentTabs = tabs.some((t) =>
PERSISTENT_TAB_TYPES.includes(t.type),
isPersistentTabType(t.type),
);
if (!hasPersistentTabs) {
const restoredTabs: Tab[] = [];
@@ -1424,16 +1379,7 @@ export function AppShell({
const host = saved.hostId
? allHosts.find((h) => h.id === String(saved.hostId))
: undefined;
const hostlessTypes: TabType[] = ["dashboard", "tunnel"];
if (!host && !hostlessTypes.includes(saved.tabType as TabType))
continue;
if (host) {
if (saved.tabType === "terminal" && !host.enableSsh) continue;
if (saved.tabType === "rdp" && !host.enableRdp) continue;
if (saved.tabType === "vnc" && !host.enableVnc) continue;
if (saved.tabType === "telnet" && !host.enableTelnet) continue;
}
if (!canRestoreTabType(saved.tabType, host)) continue;
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
const tabId = host
@@ -1457,9 +1403,7 @@ export function AppShell({
host,
openedAt: new Date(saved.createdAt).getTime(),
restoredSessionId,
terminalRef: SESSION_TAB_TYPES.includes(
saved.tabType as TabType,
)
terminalRef: isSessionTabType(saved.tabType)
? createRef()
: undefined,
});
@@ -1489,33 +1433,12 @@ export function AppShell({
}
loadSavedTabs();
}, [hostsLoaded, userPrefsLoaded]);
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
// If reopenTabsOnLogin didn't already restore anything (off, or on but
// nothing to restore), auto-apply the user's default workspace if they set
// one. Runs once, after the open-tabs restore above has had its chance —
// that path wins when both would otherwise fire, since it's more granular
// and live-session-aware than a workspace snapshot.
const defaultWorkspaceAttemptedRef = useRef(false);
// Plugins that act once the session is back (the workspaces plugin applies
// a default workspace) wait for this.
useEffect(() => {
if (!tabsReady || defaultWorkspaceAttemptedRef.current) return;
defaultWorkspaceAttemptedRef.current = true;
const hasPersistentTabs = tabs.some((t) =>
PERSISTENT_TAB_TYPES.includes(t.type),
);
if (userPrefs.reopenTabsOnLogin && hasPersistentTabs) return;
listWorkspaces()
.then((workspaces) => {
const defaultWorkspace = workspaces.find(
(w) => w.kind === "manual" && w.isDefault,
);
if (defaultWorkspace) {
applyWorkspace(defaultWorkspace);
}
})
.catch(() => {});
if (tabsReady) notifyShellReady();
}, [tabsReady]);
// Restore named split tabs once their child sessions have stable live ids. The old
@@ -1599,9 +1522,7 @@ export function AppShell({
const prevTabOrderRef = useRef<string>("");
useEffect(() => {
if (!tabsReady) return;
const persistable = tabs.filter((t) =>
PERSISTENT_TAB_TYPES.includes(t.type),
);
const persistable = tabs.filter((t) => isPersistentTabType(t.type));
const orderKey = persistable.map((t) => t.instanceId).join(",");
if (orderKey === prevTabOrderRef.current) return;
prevTabOrderRef.current = orderKey;
@@ -1619,25 +1540,10 @@ export function AppShell({
};
}, [tabs, tabsReady]);
// Debounced "Last Session" auto-save: keeps an implicit workspace snapshot
// current so the arrangement can always be recovered, even if the user never
// manually saves one. Never auto-applied on login — see the default-workspace
// effect above, which only considers kind === "manual" rows.
const lastSessionSaveTimeoutRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
// keep its last-session snapshot current.
useEffect(() => {
if (!tabsReady) return;
if (lastSessionSaveTimeoutRef.current)
clearTimeout(lastSessionSaveTimeoutRef.current);
lastSessionSaveTimeoutRef.current = setTimeout(() => {
saveLastSessionWorkspace(buildWorkspacePayload()).catch(() => {});
}, 2000);
return () => {
if (lastSessionSaveTimeoutRef.current)
clearTimeout(lastSessionSaveTimeoutRef.current);
};
if (tabsReady) notifyTabsChanged();
}, [
tabs,
paneTabIds,
@@ -1668,19 +1574,19 @@ export function AppShell({
joinShareId?: string | null;
collabRoomId?: string;
},
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) {
// A tab type owned by a disabled plugin cannot be opened, including from a
// restored session. Guarded here rather than at each call site because
// every path into a tab goes through this one.
if (!isTabTypeAvailable(type)) return null;
if (!restore && !options?.forceNewTab) {
const dataKey = JSON.stringify(options?.data ?? null);
const existing = tabsRef.current.find(
(t) =>
t.type === type &&
t.host?.id === host.id &&
t.endpointId === options?.endpointId,
JSON.stringify(t.data ?? null) === dataKey,
);
if (existing) {
setActiveTabId(existing.id);
@@ -1694,7 +1600,7 @@ export function AppShell({
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`);
const openedAt = Date.now();
const ref = SESSION_TAB_TYPES.includes(type) ? createRef() : undefined;
const ref = isSessionTabType(type) ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref);
let finalLabel = host.name;
@@ -1770,13 +1676,13 @@ export function AppShell({
initialPath,
serialConfig,
collabRoomId: restore?.collabRoomId,
endpointId: options?.endpointId,
data: options?.data,
},
];
});
setActiveTabId(tabId);
if (PERSISTENT_TAB_TYPES.includes(type)) {
if (isPersistentTabType(type)) {
addOpenTab({
id: instanceId,
tabType: type,
@@ -1792,7 +1698,11 @@ export function AppShell({
function connectHost(
host: Host,
preferredType?: TabType,
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) {
const type = resolveHostTabType(host, preferredType);
// --- tmux-monitor --- singleton tab, not a per-host tab
@@ -1946,15 +1856,12 @@ export function AppShell({
type: TabType,
pendingEvent?: string,
host?: Host,
fleetId?: number,
data?: Record<string, unknown>,
) {
// Local terminals are never singletons, each one is its own shell.
if (type === "local-terminal") {
return openLocalTerminalTab();
}
// The admin kill switch removes the assistant for everyone, so it can
// never be promoted into the tab bar while it is off.
if (type === "ai" && !aiGloballyEnabled) return;
if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") {
setSidebarOpen(true);
@@ -1990,27 +1897,28 @@ export function AppShell({
const id = type;
const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"),
docker: t("nav.docker"),
tunnel: t("nav.tunnels"),
sftp: t("nav.sftp"),
network_graph: t("nav.networkGraph"),
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
homepage: t("nav.homepage"),
"fleet-inventory": t("nav.fleets"),
};
// Promoted rail panels reuse the rail's own label so the two stay in sync.
const label = singletonLabels[type] ?? railItemLabel(type, t);
// A plugin tab names itself; promoted rail panels reuse the rail's own
// label so the two stay in sync.
const titleKey = getTabType(type)?.titleKey;
const label =
singletonLabels[type] ??
(titleKey ? t(titleKey) : railItemLabel(type, t));
setTabs((prev) => {
const existing = prev.find((t) => t.id === id);
if (existing) {
// --- tmux-monitor --- refocusing with a host preselects it
if (!host && fleetId === undefined) return prev;
if (!host && data === undefined) return prev;
return prev.map((t) =>
t.id === id
? {
...t,
...(host ? { host } : {}),
...(fleetId !== undefined ? { fleetId } : {}),
...(data !== undefined ? { data } : {}),
}
: t,
);
@@ -2024,12 +1932,12 @@ export function AppShell({
label,
openedAt: Date.now(),
...(host ? { host } : {}), // --- tmux-monitor ---
...(fleetId !== undefined ? { fleetId } : {}),
...(data !== undefined ? { data } : {}),
},
];
});
setActiveTabId(id);
if (PERSISTENT_TAB_TYPES.includes(type)) {
if (isPersistentTabType(type)) {
addOpenTab({
id,
tabType: type,
@@ -2039,24 +1947,15 @@ export function AppShell({
}).catch(() => {});
}
},
[t, aiGloballyEnabled],
[t],
);
const SESSION_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"serial",
];
const ACTIVE_CLOSE_CONFIRM_TYPES: TabType[] = SESSION_TAB_TYPES;
const getTabCloseLabel = useCallback((tab: Tab) => {
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
}, []);
const isActiveConnectionTab = useCallback((tab: Tab) => {
if (!ACTIVE_CLOSE_CONFIRM_TYPES.includes(tab.type)) return false;
if (!isSessionTabType(tab.type)) return false;
return tab.terminalRef?.current?.isConnected?.() === true;
}, []);
@@ -2084,10 +1983,7 @@ export function AppShell({
if (tabToClose?.terminalRef?.current?.disconnect) {
tabToClose.terminalRef.current.disconnect();
}
if (
tabToClose?.instanceId &&
PERSISTENT_TAB_TYPES.includes(tabToClose.type)
) {
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
deleteOpenTab(tabToClose.instanceId).catch(() => {});
}
@@ -2136,15 +2032,11 @@ export function AppShell({
function refreshTab(id: string) {
const tab = tabs.find((t) => t.id === id);
if (!tab) return;
if (tab.type === "terminal") {
const ref = tab.terminalRef?.current;
ref?.reconnect?.();
} else if (["rdp", "vnc", "telnet"].includes(tab.type)) {
window.dispatchEvent(
new CustomEvent("termix:refresh-guacamole", { detail: { tabId: id } }),
);
}
const handle = tab?.terminalRef?.current;
if (!handle) return;
// Session tabs expose one or the other on their handle.
if (handle.reconnect) handle.reconnect();
else handle.refresh?.();
}
function openShareForTab(id: string) {
@@ -2188,23 +2080,13 @@ export function AppShell({
return;
}
if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
if (tab && isSessionTabType(tab.type) && confirmEnabled) {
toast.dismiss(`close-tab-${id}`);
}
doCloseTab(id);
}
// An admin can turn the assistant off while tabs are already open, and a
// saved workspace or restored session can bring one back. Either way the
// leftover tab and panel go away as soon as the status says it is off.
useEffect(() => {
if (!aiStatusLoaded || aiGloballyEnabled) return;
if (tabs.some((tab) => tab.type === "ai")) doCloseTab("ai");
setRailView((prev) => (prev === "ai" ? "hosts" : prev));
setRightRailView((prev) => (prev === "ai" ? null : prev));
}, [aiStatusLoaded, aiGloballyEnabled, tabs]);
closeActiveTabRef.current = () => {
const id = activeTabIdRef.current;
if (id !== "dashboard") closeTab(id);
@@ -2332,7 +2214,7 @@ export function AppShell({
setRightRailView(null);
return;
}
const fallback = lastRightRailViewRef.current ?? RIGHT_DOCKABLE_IDS[0];
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
if (fallback) setRightRailView(fallback as RailView);
}
@@ -2544,6 +2426,79 @@ export function AppShell({
*/
// The param deliberately shadows the outer railView so the body reads the
// same whichever dock is rendering.
const newInstanceId = () =>
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
/**
* What plugins may ask of the shell. Rebuilt every render so it always
* closes over current state, and published to the plugin runtime.
*/
const shellCallbacks: TabShellCallbacks = {
openTab: (host, type, options) => {
if (!host || getTabType(type)?.singleton) {
openSingletonTab(type, undefined, host ?? undefined, options?.data);
return;
}
openTab(host, type, undefined, options);
},
openSingletonTab: (type, options) =>
openSingletonTab(type, undefined, undefined, options?.data),
closeTab: (tabId) => closeTab(tabId),
renameTab: (tabId, label) => renameTab(tabId, label),
openFileInEditor: (host, filePath) =>
openTab(host, "files", {
instanceId: newInstanceId(),
restoredSessionId: null,
initialFilePath: filePath,
}),
openFileManager: (host, path) =>
openTab(host, "files", {
instanceId: newInstanceId(),
restoredSessionId: null,
initialPath: path,
}),
openTerminalTab: (host, path) =>
openTab(host, "terminal", {
instanceId: newInstanceId(),
restoredSessionId: null,
initialFilePath: path,
}),
openRailView: (id) => {
setRailView(id as RailView);
setSidebarOpen(true);
},
closeRailView: (id) => {
setRailView((prev) => (prev === id ? "hosts" : prev));
setRightRailView((prev) => (prev === id ? null : prev));
},
saveQuickConnect: saveQuickConnectHost,
};
// Panels close the sidebar on mobile after opening something, the same as
// the hosts panel does.
const panelShell: TabShellCallbacks = {
...shellCallbacks,
openTab: (...args) => {
shellCallbacks.openTab(...args);
if (isMobile) setSidebarOpen(false);
},
};
useEffect(() => {
setShellCallbacks(shellCallbacks);
setShellLayoutProvider({
getLayout: () => (tabsReady ? buildWorkspacePayload() : null),
applyLayout: (layout, options) =>
applyLayout(layout as WorkspacePayload, options?.name ?? ""),
});
});
useEffect(() => {
setShellHosts(allHosts, hostsLoaded);
}, [allHosts, hostsLoaded]);
const renderSidebarPanels = (railView: RailView, owned = true) => (
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
@@ -2606,15 +2561,6 @@ export function AppShell({
/>
)}
{railView === "tailscale" && (
<TailscaleDevicesPanel
onConnect={(host, type) => {
openTab(host, type);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "ssh-tools" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SshToolsPanel
@@ -2648,24 +2594,6 @@ export function AppShell({
</div>
)}
{owned && (
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "fleets" ? "" : "hidden"}`}
>
<FleetsPanel
active={railView === "fleets"}
onOpenFleetInventory={(fleetId) =>
openSingletonTab(
"fleet-inventory",
undefined,
undefined,
fleetId,
)
}
/>
</div>
)}
{railView === "history" && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<HistoryPanel
@@ -2693,33 +2621,32 @@ export function AppShell({
</div>
)}
{railView === "workspaces" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<WorkspacesPanel
active={railView === "workspaces"}
currentPayload={buildWorkspacePayload}
onApplyWorkspace={applyWorkspace}
/>
</div>
)}
{registeredPanels.map((panel) => {
const shown = railView === panel.id;
// A kept-mounted panel lives in the owning dock only, so two live
// copies never fight over the same state.
if (panel.keepMounted ? !owned : !shown) return null;
const Panel = panel.component;
return (
<div
key={panel.id}
className={`flex flex-col flex-1 min-h-0 overflow-y-auto ${shown ? "" : "hidden"}`}
>
<Panel
active={shown}
shell={panelShell}
setEditing={setSidebarEditing}
activeTabType={
tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined
}
placement={owned ? "left" : "right"}
/>
</div>
);
})}
{railView === "automations" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<AutomationsPanel
active={railView === "automations"}
onEditingChange={setSidebarEditing}
/>
</div>
)}
{railView === "ai" && (
<div className="flex flex-col flex-1 min-h-0">
<AiPanel
activeTab={
tabs.find((tab) => tab.id === activeTabId)?.type ?? null
}
/>
</div>
{!isCoreRailView(railView) && !getPanel(railView) && (
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
)}
{railView === "connections" && (
@@ -2918,7 +2845,7 @@ export function AppShell({
<span className="flex-1 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
{sidebarTitle(railView)}
</span>
{!isMobile && PROMOTABLE_IDS.includes(railView) && (
{!isMobile && promotableIds().includes(railView) && (
<>
<Separator orientation="vertical" />
<Button
@@ -2933,7 +2860,7 @@ export function AppShell({
</Button>
</>
)}
{!isMobile && RIGHT_DOCKABLE_IDS.includes(railView) && (
{!isMobile && rightDockableIds().includes(railView) && (
<>
<Separator orientation="vertical" />
<Button
@@ -3007,8 +2934,8 @@ export function AppShell({
const sidebarHint = !isMobile && (
<MultiPanelHint
canPromote={PROMOTABLE_IDS.includes(railView)}
canRightDock={RIGHT_DOCKABLE_IDS.includes(railView)}
canPromote={promotableIds().includes(railView)}
canRightDock={rightDockableIds().includes(railView)}
onOpenAsTab={() => openSingletonTab(railView as TabType)}
onOpenInRightDock={() => openInRightDock(railView)}
/>
@@ -3189,43 +3116,11 @@ export function AppShell({
? paneIdx === (focusedPaneIndex ?? 0)
: activeInline;
return createPortal(
renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
inPane || activeInline,
(host, filePath) =>
openTab(host, "files", {
instanceId:
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
restoredSessionId: null,
initialFilePath: filePath,
}),
(host, path) =>
openTab(host, "files", {
instanceId:
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
restoredSessionId: null,
initialPath: path,
}),
(host, path) =>
openTab(host, "terminal", {
instanceId:
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
restoredSessionId: null,
initialFilePath: path,
}),
renameTab,
saveQuickConnectHost,
renderTabContent(tab, {
shell: shellCallbacks,
isVisible: inPane || activeInline,
isFocusedPane,
{
panelProps: {
terminalTabs,
targetTerminalTabId,
storageMode:
@@ -3233,7 +3128,7 @@ export function AppShell({
? "cloud"
: "local",
},
),
}),
tabNode,
tab.id,
);
@@ -3330,7 +3225,7 @@ export function AppShell({
</Suspense>
<OnboardingDialog
open={showOnboarding}
context={{ aiGloballyEnabled: onboardingAiEnabled }}
context={{}}
onClose={() => setShowOnboarding(false)}
/>
<DonationReminderModal
-308
View File
@@ -1,308 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
export interface AiProvider {
id: number;
providerType: AiProviderType;
label: string;
baseUrl: string | null;
/** The first few characters only; the key itself never leaves the server. */
apiKeyPrefix: string | null;
defaultModel: string | null;
enabled: boolean;
createdAt: string;
}
export interface AiConversation {
id: number;
title: string | null;
providerId: number | null;
model: string | null;
createdAt: string;
updatedAt: string;
}
export interface AiMessage {
id: number;
conversationId: number;
role: "user" | "assistant" | "tool";
content: string;
toolCalls: string | null;
createdAt: string;
}
export interface AiProposal {
id: number;
conversationId: number;
kind: string;
summary: string | null;
payload: string;
status: "pending" | "applied" | "rejected" | "expired";
resultSummary: string | null;
createdAt: string;
}
export interface AiStatus {
globallyEnabled: boolean;
enabled: boolean;
allowReadOnlyCommands: boolean;
}
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/plugin-api/ai/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
}
}
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
}
}
export async function createAiProvider(input: {
providerType: AiProviderType;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/plugin-api/ai/providers", input)).data
.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
}
}
export async function updateAiProvider(
id: number,
input: Partial<{
label: string;
baseUrl: string | null;
apiKey: string | null;
defaultModel: string | null;
enabled: boolean;
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
}
}
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
}
}
/**
* Model list for a provider that may not be saved yet, so the add form can
* fill its picker before anything is persisted.
*/
export async function probeAiModels(input: {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
providerId?: number | null;
}): Promise<{
models: string[];
source: "live" | "fallback" | "none";
warning?: string;
}> {
try {
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
}
}
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
.models;
} catch (error) {
throw handleApiError(error, "list provider models");
}
}
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/plugin-api/ai/conversations")).data
.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
}
}
export async function getAiConversation(id: number): Promise<{
conversation: AiConversation;
messages: AiMessage[];
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
}
}
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
}
}
export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
}
}
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
}
}
/**
* For the terminal-docked assistant: records that a run_command proposal was
* typed into the user's open terminal instead of run over a pooled connection.
*/
export async function markAiProposalRunInTerminal(
id: number,
hostId: number,
summary?: string,
): Promise<{ success: boolean; summary: string }> {
try {
return (
await authApi.post(
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
{
hostId,
summary,
},
)
).data;
} catch (error) {
throw handleApiError(error, "mark AI proposal run in terminal");
}
}
// --- admin ---
export async function getAiGloballyEnabled(): Promise<boolean> {
try {
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
}
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
}
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
}
export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
}
}
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/notification-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get notification endpoint allowlist");
}
}
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get Step CA endpoint allowlist");
}
}
export async function setStepCaPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update Step CA endpoint allowlist");
}
}
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/secret-source-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get secret source endpoint allowlist");
}
}
export async function setSecretSourcePrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update secret source endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/notification-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update notification endpoint allowlist");
}
}
+3 -18
View File
@@ -13,15 +13,8 @@ export interface UptimeInfo {
export interface RecentActivityItem {
id: number;
userId: string;
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "telnet"
| "vnc"
| "rdp";
/** Core records file_manager and tunnel; plugin tabs record their own. */
type: string;
hostId: number;
hostName: string;
timestamp: string;
@@ -50,15 +43,7 @@ export async function getRecentActivity(
}
export async function logActivity(
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "rdp"
| "vnc"
| "telnet",
type: string,
hostId: number,
hostName: string,
): Promise<{ message: string; id: number | string }> {
-345
View File
@@ -1,345 +0,0 @@
import axios from "axios";
import { dockerApi, handleApiError } from "@/main-axios";
import type {
DockerContainer,
DockerLogOptions,
DockerStats,
DockerValidation,
} from "@/types/index";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: string;
};
export async function connectDockerSession(
sessionId: string,
hostId: number,
config?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
},
): Promise<{
success?: boolean;
message?: string;
requires_totp?: boolean;
prompt?: string;
isPassword?: boolean;
status?: string;
reason?: string;
connectionLogs?: ApiConnectionLog[];
requires_warpgate?: boolean;
url?: string;
securityKey?: string;
}> {
try {
const response = await dockerApi.post("/ssh/connect", {
sessionId,
hostId,
...config,
});
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.status === "auth_required"
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_totp
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_warpgate
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
throw handleApiError(error, "connect to Docker SSH session");
}
}
export async function verifyDockerTOTP(
sessionId: string,
totpCode: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker TOTP");
}
}
export async function verifyDockerWarpgate(
sessionId: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker Warpgate");
}
}
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "disconnect from Docker SSH session");
}
}
export async function keepaliveDockerSession(
sessionId: string,
): Promise<{ success: boolean }> {
try {
const response = await dockerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "keepalive Docker SSH session");
}
}
export async function getDockerSessionStatus(
sessionId: string,
): Promise<{ success: boolean; connected: boolean }> {
try {
const response = await dockerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker session status");
}
}
export async function validateDockerAvailability(
sessionId: string,
): Promise<DockerValidation> {
try {
const response = await dockerApi.get(`/validate/${sessionId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "validate Docker availability");
}
}
export async function listDockerContainers(
sessionId: string,
all: boolean = true,
): Promise<DockerContainer[]> {
try {
const response = await dockerApi.get(`/containers/${sessionId}`, {
params: { all },
});
return response.data;
} catch (error) {
throw handleApiError(error, "list Docker containers");
}
}
export async function getDockerContainerDetails(
sessionId: string,
containerId: string,
): Promise<DockerContainer> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker container details");
}
}
export async function startDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/start`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start Docker container");
}
}
export async function stopDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/stop`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "stop Docker container");
}
}
export async function restartDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/restart`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "restart Docker container");
}
}
export async function pauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/pause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "pause Docker container");
}
}
export async function unpauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/unpause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unpause Docker container");
}
}
export async function removeDockerContainer(
sessionId: string,
containerId: string,
force: boolean = false,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.delete(
`/containers/${sessionId}/${containerId}/remove`,
{
params: { force },
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
}
export async function getContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<{ logs: string }> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: options,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container logs");
}
}
export async function downloadContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<Blob> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: { ...options, download: true },
responseType: "blob",
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "download container logs");
}
}
export async function getContainerStats(
sessionId: string,
containerId: string,
): Promise<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
}
}
-300
View File
@@ -1,300 +0,0 @@
import {
authApi,
handleApiError,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios";
export interface FleetRow {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string[];
syncId: string | null;
createdAt: string;
updatedAt: string;
memberCount: number;
}
export interface FleetMemberRow {
id: number;
name: string;
ip: string;
tags: string[];
static: boolean;
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
}
export interface FleetHostResult {
hostId: number;
hostName: string;
success: boolean;
output?: string;
error?: string;
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryEntry {
hostId: number;
hostName: string;
inventory: FleetInventoryRecord | null;
}
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/plugin-api/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
}
}
export async function createFleet(fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/plugin-api/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
}
}
export async function updateFleet(
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(
`/plugin-api/fleets/${fleetId}`,
fleetData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
}
}
export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
}
}
export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
}
}
export async function addFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/members`,
{
hostId,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
}
}
export async function removeFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove fleet member");
}
}
export async function runFleetCommand(
fleetId: number,
command: string,
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/execute`,
{
command,
...(inputValues ? { inputValues } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
}
}
export async function pushFleetFile(
fleetId: number,
file: File,
remotePath: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return response.data;
} catch (error) {
throw handleApiError(error, "push file to fleet");
}
}
export async function pullFleetFile(
fleetId: number,
remotePath: string,
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const resultsHeader = response.headers["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = response.headers["content-disposition"] as
string | undefined;
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
} catch (error) {
throw handleApiError(error, "pull file from fleet");
}
}
export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
}
}
export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
}
}
export async function runFleetPackageAction(
fleetId: number,
action: FleetPackageAction,
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/packages`,
{
action,
...(packageName ? { package: packageName } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
}
}
export interface FleetShareHostResult {
hostId: number;
shared: boolean;
reason?: string;
}
export async function shareFleet(
fleetId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
): Promise<{
success: boolean;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
hostsShared: number;
hostsTotal: number;
hostResults: FleetShareHostResult[];
}> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share fleet");
}
}
-146
View File
@@ -1,146 +0,0 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- a synced
// host has a different numeric id on each side (only its syncId matches
// across them). These calls always target the embedded local backend; see
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
// call that IS safely merged across local + remote (a process-local,
// in-memory aggregate keyed by whichever host ids that process happens to
// know about, not a per-host lookup).
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface MetricsHistoryResponse {
rows: MetricsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
const res = await statsApi.get(`/metrics/history/${hostId}`, {
params: opts,
});
return res.data as MetricsHistoryResponse;
}
export async function getMetricsHistoryRetention(): Promise<number> {
const res = await statsApi.get("/global-settings/history");
return (res.data as { metricsHistoryRetentionDays: number })
.metricsHistoryRetentionDays;
}
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
await statsApi.post("/global-settings/history", {
metricsHistoryRetentionDays: days,
});
}
/**
* Host Metrics layout persistence (server-synced per user/host) + the manager
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
* stats app (port 30005).
*/
export async function getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
validateStatus: (status) => status === 200 || status === 404,
});
if (res.status === 404) return null;
return res.data?.layout ?? null;
} catch (error) {
handleApiError(error, "fetch host metrics layout");
throw error;
}
}
export async function saveHostMetricsLayout(
hostId: number,
layout: HostMetricsLayout,
): Promise<void> {
try {
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
} catch (error) {
handleApiError(error, "save host metrics layout");
throw error;
}
}
// ─── Managers ───────────────────────────────────────────────────────────────
export interface PlatformInfo {
hasSystemd: boolean;
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
return res.data;
}
/** GET a manager resource (read). */
export async function managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}`,
{ params },
);
return res.data as T;
}
/**
* GET a manager sub-resource where the host id sits in the middle of the path,
* e.g. /managers/logs/{id}/files.
*/
export async function managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
);
return res.data as T;
}
/**
* POST a manager action. `resource` is the manager name; `action` is the
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
* sits between them: /managers/{resource}/{id}[/{action}].
*/
export async function managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
const res = await statsApi.post(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
);
return res.data as T;
}
+22
View File
@@ -46,9 +46,21 @@ export interface PluginSettingsContribution {
host?: PluginHostSettingsContribution;
}
/** A panel or dashboard card, declared so its owner is known while it is off. */
export interface PluginViewContribution {
id: string;
titleKey: string;
icon?: string;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[];
/** The frontend also runs on anonymous guest pages. */
guest?: boolean;
settings?: PluginSettingsContribution;
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
}
/** A secret as it arrives from the server. The value never leaves the server. */
@@ -173,6 +185,16 @@ export interface PluginSummary {
contributes: PluginContributions | null;
/** Lucide icon name from the manifest. */
icon?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
/** A built frontend bundle is served at /plugin-assets/<id>/frontend.js. */
frontend?: boolean;
/** A frontend.css sits beside the bundle. */
css?: boolean;
/** Cache key for the bundle; changes when it is rebuilt. */
assetVersion?: string | null;
/** "en" plus the xx_YY names of shipped translations. */
locales?: string[];
tier?: string;
source?: string;
+67
View File
@@ -0,0 +1,67 @@
import { authApi, handleApiError } from "@/main-axios";
/**
* Admin allowlists of private addresses that outbound requests may reach, one
* per core feature that makes them.
*/
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/notification-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get notification endpoint allowlist");
}
}
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get Step CA endpoint allowlist");
}
}
export async function setStepCaPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update Step CA endpoint allowlist");
}
}
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/secret-source-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get secret source endpoint allowlist");
}
}
export async function setSecretSourcePrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update secret source endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/notification-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update notification endpoint allowlist");
}
}
-93
View File
@@ -1,93 +0,0 @@
import axios from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- same caveat as
// host-metrics-api.ts / host-metrics-status-api.ts. All routes live under the
// `/proxmox-stats/*` prefix on the stats app (port 30005).
export async function getProxmoxStats(
hostId: number,
): Promise<ProxmoxStatsSnapshot | null> {
try {
const response = await statsApi.get(`/proxmox-stats/${hostId}`, {
// Treat 404 as an expected "no stats yet / disabled" signal rather than
// an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch proxmox stats");
throw error;
}
}
export async function startProxmoxStatsPolling(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}> {
try {
const response = await statsApi.post(`/proxmox-stats/start/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "start proxmox stats polling");
throw error;
}
}
export async function stopProxmoxStatsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/proxmox-stats/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop proxmox stats polling");
throw error;
}
}
export async function sendProxmoxStatsHeartbeat(
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/proxmox-stats/heartbeat", { viewerSessionId });
} catch (error) {
handleApiError(error, "send proxmox stats heartbeat");
throw error;
}
}
export interface ProxmoxStatsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface ProxmoxStatsHistoryResponse {
rows: ProxmoxStatsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getProxmoxStatsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<ProxmoxStatsHistoryResponse> {
const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, {
params: opts,
});
return res.data as ProxmoxStatsHistoryResponse;
}
+111 -81
View File
@@ -13,12 +13,10 @@ import {
KeyRound,
LayoutDashboard,
Link,
MessagesSquare,
Network,
Plus,
Server,
Settings,
Terminal,
Trash2,
User,
Zap,
@@ -50,7 +48,18 @@ import {
} from "@/main-axios";
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
import { useTranslation } from "react-i18next";
import { NetworkGraphCard } from "../../../plugins/network-topology/src/frontend/NetworkGraphCard";
import {
getRegisteredDashboardCard,
useRegisteredDashboardCards,
} from "./dashboard-cards-registry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
import { activityTarget } from "@/lib/activity-types";
import { shell } from "@/plugin-host/shell-bridge";
import {
defaultConnectAction,
hostActionsFor,
listHostActions,
} from "@/sidebar/host-contributions";
import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard";
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
@@ -126,6 +135,51 @@ const DEFAULT_SLOTS: CardSlot[] = [
},
];
/** Cards core draws itself; any other id belongs to a plugin. */
const CORE_CARD_IDS = new Set<string>([
"stats_bar",
"counters_bar",
"quick_actions",
"host_status",
"recent_activity",
"service_links",
"homepage_preview",
]);
/**
* A plugin's card, or a placeholder that keeps the slot while its plugin is
* off, so the card comes back in the same place when the plugin does.
*/
export function PluginCardSlot({
id,
isVisible,
onOpenSingletonTab,
}: {
id: string;
isVisible: boolean;
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) {
useRegisteredDashboardCards();
const card = getRegisteredDashboardCard(id);
if (!card) {
return (
<Card className="flex h-full w-full overflow-hidden py-0">
<PluginViewPlaceholder kind="card" viewId={id} compact />
</Card>
);
}
const Component = card.component;
return (
<Component
isVisible={isVisible}
shell={{
...shell,
openSingletonTab: (type) => onOpenSingletonTab(type),
}}
/>
);
}
// ─── Card components ──────────────────────────────────────────────────────────
function StatsBarCard({
@@ -261,23 +315,20 @@ function QuickActionsCard({
const { t } = useTranslation();
const pinnedHosts = hosts.filter((h) => h.pin);
const getConnectionEndpoint = (host: Host) => {
const type = getDefaultConnectionTab(host);
const port =
type === "rdp"
const port = host.enableSsh
? host.sshPort
: host.enableRdp
? host.rdpPort
: type === "vnc"
: host.enableVnc
? host.vncPort
: type === "telnet"
: host.enableTelnet
? host.telnetPort
: host.sshPort;
return `${host.ip}:${port}`;
: host.port;
return `${host.ip}:${port ?? host.port}`;
};
const renderConnectionIcon = (host: Host) => {
const type = getDefaultConnectionTab(host);
if (type === "terminal" || type === "telnet") {
return <Terminal className="size-3 text-accent-brand" />;
}
return <Server className="size-3 text-accent-brand" />;
const Icon = defaultConnectAction(listHostActions(), host)?.icon ?? Server;
return <Icon className="size-3 text-accent-brand" />;
};
return (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
@@ -473,20 +524,14 @@ export function HostStatusCard({
return (
<div
key={i}
onClick={() =>
onOpenTab(
host,
host.enableSsh
? "host-metrics"
: host.enableRdp
? "rdp"
: host.enableVnc
? "vnc"
: host.enableTelnet
? "telnet"
: "host-metrics",
)
}
onClick={() => {
// An overview action (a metrics view) wins over connecting.
const actions = hostActionsFor(listHostActions(), host);
const target =
actions.find((action) => action.overview)?.tabType ??
defaultConnectAction(actions, host)?.tabType;
if (target) onOpenTab(host, target);
}}
className="flex min-w-0 items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
>
<div className="flex min-w-0 flex-1 items-center gap-2.5">
@@ -580,35 +625,13 @@ function RecentActivityCard({
}) {
const { t } = useTranslation();
const statusScheme = useStatusColorScheme();
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
terminal: <Terminal className="size-2.5" />,
file_manager: <Server className="size-2.5" />,
server_stats: <Activity className="size-2.5" />,
tunnel: <Network className="size-2.5" />,
docker: <Server className="size-2.5" />,
rdp: <Server className="size-2.5" />,
vnc: <Server className="size-2.5" />,
telnet: <MessagesSquare className="size-2.5" />,
const typeIcon = (type: string): React.ReactNode => {
const Icon = activityTarget(type)?.icon ?? Server;
return <Icon className="size-2.5" />;
};
const typeToTab: Record<RecentActivityItem["type"], TabType> = {
terminal: "terminal",
file_manager: "files",
server_stats: "host-metrics",
tunnel: "tunnel",
docker: "docker",
rdp: "rdp",
vnc: "vnc",
telnet: "telnet",
};
const typeLabel: Record<RecentActivityItem["type"], string> = {
terminal: t("networkGraph.terminal"),
file_manager: t("networkGraph.fileManager"),
server_stats: t("networkGraph.serverStats"),
tunnel: t("networkGraph.tunnel"),
docker: t("networkGraph.docker"),
rdp: "RDP",
vnc: "VNC",
telnet: "Telnet",
const typeLabel = (type: string): string => {
const labelKey = activityTarget(type)?.labelKey;
return labelKey ? t(labelKey) : type.replace("_", " ");
};
function formatTime(ts: string) {
const diffMs = Date.now() - new Date(ts).getTime();
@@ -649,7 +672,8 @@ function RecentActivityCard({
<div
key={item.id}
onClick={() => {
if (host) onOpenTab(host, typeToTab[item.type]);
const target = activityTarget(item.type);
if (host && target) onOpenTab(host, target.tab);
}}
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
>
@@ -662,8 +686,8 @@ function RecentActivityCard({
{item.hostName}
</span>
<div className="flex items-center gap-1 text-muted-foreground">
{typeIcon[item.type]}
<span className="text-[10px]">{typeLabel[item.type]}</span>
{typeIcon(item.type)}
<span className="text-[10px]">{typeLabel(item.type)}</span>
</div>
</div>
</div>
@@ -958,11 +982,11 @@ function CardItem({
statusLoading={statusLoading}
/>
)}
{slot.id === "network_graph" && (
<NetworkGraphCard
embedded={true}
{!CORE_CARD_IDS.has(slot.id) && (
<PluginCardSlot
id={slot.id}
isVisible={isVisible}
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
onOpenSingletonTab={onOpenSingletonTab}
/>
)}
{slot.id === "service_links" && (
@@ -1031,12 +1055,16 @@ function AddCardTray({
onAdd,
cardLabels,
}: {
activeIds: DashboardCardId[];
onAdd: (id: DashboardCardId) => void;
cardLabels: Record<DashboardCardId, string>;
activeIds: string[];
onAdd: (id: string) => void;
cardLabels: Record<string, string>;
}) {
const { t } = useTranslation();
const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id));
const registeredCards = useRegisteredDashboardCards();
const available = [
...DASHBOARD_CARDS.map((card) => ({ id: card.id as string })),
...registeredCards.map((card) => ({ id: card.id })),
].filter((c) => !activeIds.includes(c.id));
if (available.length === 0) return null;
return (
<div className="flex items-center gap-2 px-1 py-2 flex-wrap shrink-0">
@@ -1241,6 +1269,7 @@ export function DashboardTab({
/** When false, pause dashboard metrics refresh while the tab stays mounted. */
isVisible?: boolean;
}) {
const registeredCards = useRegisteredDashboardCards();
const { t, i18n } = useTranslation();
const { initialLoadComplete } = useServerStatus();
const statusLoading = !initialLoadComplete;
@@ -1561,15 +1590,17 @@ export function DashboardTab({
.sort((a, b) => a.order - b.order);
const hasSide = sideSlots.length > 0;
const cardLabels: Record<DashboardCardId, string> = {
const cardLabels: Record<string, string> = {
stats_bar: t("dashboard.serverOverview"),
counters_bar: t("dashboard.serverStats"),
quick_actions: t("dashboard.quickActions"),
host_status: t("dashboardTab.hostStatus"),
recent_activity: t("dashboard.recentActivity"),
network_graph: t("dashboard.networkGraph"),
service_links: t("dashboard.serviceLinks"),
homepage_preview: t("dashboard.homepagePreview"),
...Object.fromEntries(
registeredCards.map((card) => [card.id, t(card.titleKey)]),
),
};
const onColumnDividerMouseDown = useCallback(
@@ -1640,13 +1671,12 @@ export function DashboardTab({
? Math.max(...panelSlots.map((s) => s.order)) + 1
: 0;
const defaultHeight: number | null =
id === "network_graph"
? 350
: id === "host_status" || id === "recent_activity"
? null
: id === "service_links"
? 200
: 150;
getRegisteredDashboardCard(id)?.defaultHeight ??
(id === "host_status" || id === "recent_activity"
? null
: id === "service_links"
? 200
: 150);
const key = `${id}_${Date.now()}`;
return [
...prev,
@@ -1828,11 +1858,11 @@ export function DashboardTab({
statusLoading={statusLoading}
/>
)}
{slot.id === "network_graph" && (
<NetworkGraphCard
embedded={true}
{!CORE_CARD_IDS.has(slot.id) && (
<PluginCardSlot
id={slot.id}
isVisible={isVisible}
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
onOpenSingletonTab={onOpenSingletonTab}
/>
)}
{slot.id === "service_links" && (
+23 -24
View File
@@ -1,33 +1,32 @@
import type { ReactNode } from "react";
import type { ComponentType } from "react";
import { createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "@/shell/tab-registry";
export interface DashboardCardRenderProps {
isVisible: boolean;
/** The shell, e.g. to open the full-page version of the card. */
shell: TabShellCallbacks;
}
/**
* Runtime registry for plugin-contributed dashboard cards.
*
* Unlike registerRailItem/registerTabComponent (rail-items.ts, tabUtils.tsx),
* nothing in DashboardTab.tsx consumes this yet: its card system
* (DASHBOARD_CARDS in @/lib/theme, DashboardCardId in @/types/ui-types) is a
* closed, typed set with its own drag/drop layout and per-user persisted
* slot preferences, and no plugin today contributes an actual dashboard
* card. This registry exists so a plugin CAN register one without another
* round of seam-building, but adding it to the DashboardTab render/layout
* system is future work, not part of this change.
* A dashboard card a plugin contributes. DashboardTab offers registered cards
* next to the core ones, and a saved slot whose card is not registered keeps
* its place and shows a placeholder, so turning a plugin off and on again
* puts the card back where it was.
*/
export interface RegisteredDashboardCard {
id: string;
pluginId?: string;
titleKey: string;
render: () => ReactNode;
defaultHeight?: number;
component: ComponentType<DashboardCardRenderProps>;
}
const registeredDashboardCards = new Map<string, RegisteredDashboardCard>();
const registry = createRegistry<RegisteredDashboardCard>();
export function registerDashboardCard(def: RegisteredDashboardCard): void {
registeredDashboardCards.set(def.id, def);
}
export function unregisterDashboardCard(id: string): void {
registeredDashboardCards.delete(id);
}
export function registeredDashboardCardList(): RegisteredDashboardCard[] {
return [...registeredDashboardCards.values()];
}
export const registerDashboardCard = registry.register;
export const unregisterDashboardCard = registry.unregister;
export const getRegisteredDashboardCard = registry.get;
export const registeredDashboardCardList = registry.list;
export const useRegisteredDashboardCards = registry.useList;
export const resetDashboardCards = registry.reset;
-23
View File
@@ -1,23 +0,0 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AiMessageProps {
role: "user" | "assistant";
content: string;
}
export function AiMessage({ role, content }: AiMessageProps) {
if (role === "user") {
return (
<div className="rounded-none border border-border bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
{content}
</div>
);
}
return (
<div className="text-sm leading-relaxed [&_a]:underline [&_code]:font-mono [&_code]:text-xs [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
-533
View File
@@ -1,533 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
import { Button } from "@/components/button";
import { Textarea } from "@/components/textarea";
import { saveUserPreferences } from "@/main-axios";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
getAiConversation,
getAiProviders,
getAiStatus,
type AiProposal,
type AiProvider,
} from "@/api/ai-api";
import { AiMessage } from "./AiMessage";
import { AiProviderSettings } from "./AiProviderSettings";
import { AiToolCall } from "./AiToolCall";
import { ProposalCard } from "./ProposalCard";
import { useAiStream, type ToolActivity } from "./use-ai-stream";
import {
activeMentionQuery,
useMentions,
type MentionItem,
} from "./useMentions";
import { mentionLabel } from "./labels";
interface HistoryEntry {
role: "user" | "assistant";
content: string;
}
/** One entry in the rendered conversation, in the order it happened. */
type TimelineItem =
| {
kind: "message";
key: string;
role: "user" | "assistant";
content: string;
}
| { kind: "tool"; key: string; tool: ToolActivity }
| { kind: "proposal"; key: string; proposal: AiProposal };
export function AiPanel({ activeTab }: { activeTab?: string | null }) {
const { t } = useTranslation();
const { state, send, stop } = useAiStream();
const [providers, setProviders] = useState<AiProvider[]>([]);
const [providerId, setProviderId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [input, setInput] = useState("");
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [resolvedProposals, setResolvedProposals] = useState<
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
>({});
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [mention, setMention] = useState<{
query: string;
start: number;
} | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const { search: searchMentions } = useMentions(Boolean(providerId));
const mentionMatches = mention ? searchMentions(mention.query) : [];
const updateMentionQuery = (value: string, caret: number) => {
const next = activeMentionQuery(value, caret);
setMention(next);
setMentionIndex(0);
};
/**
* Replaces the "@partial" under the caret with the chosen name. The mention
* is plain text: the assistant still has to call a read tool to look the
* item up, so nothing is attached to the prompt behind the user's back.
*/
const insertMention = (item: MentionItem) => {
if (!mention) return;
const caret = textareaRef.current?.selectionStart ?? input.length;
const before = input.slice(0, mention.start);
const after = input.slice(caret);
const inserted = `@${item.label} `;
setInput(`${before}${inserted}${after}`);
setMention(null);
requestAnimationFrame(() => {
const position = before.length + inserted.length;
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(position, position);
});
};
const loadProviders = useCallback(async (selectId?: number) => {
try {
const list = await getAiProviders();
setProviders(list);
setProviderId((current) => {
// A provider that was just added becomes the active one, so there is
// no second step between creating it and being able to use it.
if (selectId && list.some((entry) => entry.id === selectId)) {
return selectId;
}
// Fall back to the first if the current selection has gone away.
if (current && list.some((entry) => entry.id === current)) {
return current;
}
return list[0]?.id ?? null;
});
return list;
} catch {
setProviders([]);
return [];
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const status = await getAiStatus();
if (cancelled) return;
// Opening the panel is consent to use it. Without this every write
// 403s through the gate, which surfaces as a bare permission error
// rather than anything the user can act on.
if (status.globallyEnabled && !status.enabled) {
await saveUserPreferences({ aiAssistantEnabled: true });
}
if (!status.globallyEnabled) return;
const list = await loadProviders();
// With nothing configured there is nothing to chat with, so go
// straight to the form instead of showing a card that just vanishes.
if (!cancelled && list.length === 0) setShowSettings(true);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [loadProviders]);
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [state.assistantText, state.tools.length, history.length]);
const reloadHistory = useCallback(
async (conversationId: number | null, reply: string) => {
// Reload from the server so the transcript is the stored one rather
// than a client-side reconstruction.
if (conversationId) {
try {
const loaded = await getAiConversation(conversationId);
setHistory(
loaded.messages
.filter((entry) => entry.role !== "tool")
.map((entry) => ({
role: entry.role as "user" | "assistant",
content: entry.content,
})),
);
return;
} catch {
// Fall through to keeping the streamed copy.
}
}
// No stored copy to fall back on, so the streamed reply is appended
// locally. Without this it would vanish when the next turn resets the
// stream state.
if (reply) {
setHistory((prev) => [...prev, { role: "assistant", content: reply }]);
}
},
[],
);
async function handleSend() {
const message = input.trim();
if (!message || !providerId || state.streaming) return;
setInput("");
setHistory((prev) => [...prev, { role: "user", content: message }]);
await send({
message,
providerId,
conversationId: state.conversationId,
activeTab,
onComplete: reloadHistory,
});
}
/**
* Records the outcome on the card and tells the assistant what happened.
*
* Without the follow-up the run just stops: the assistant proposed a
* command, the user approved it, and the model never learns it produced
* output, so it sits there as if the tool were still running.
*/
function handleProposalResolved(
id: number,
status: "applied" | "rejected",
resultSummary?: string,
) {
setResolvedProposals((prev) => ({
...prev,
[id]: { status, resultSummary },
}));
if (!providerId || state.streaming) return;
const proposal = state.proposals.find((entry) => entry.id === id);
const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`;
const followUp =
status === "applied"
? resultSummary
? `I approved "${label}". Result:\n\n${resultSummary}`
: `I approved "${label}". It completed.`
: `I rejected "${label}". Do not retry it unless I ask.`;
setHistory((prev) => [...prev, { role: "user", content: followUp }]);
void send({
message: followUp,
providerId,
conversationId: state.conversationId,
activeTab,
onComplete: reloadHistory,
});
}
const proposals: AiProposal[] = state.proposals.map((proposal) => {
const resolved = resolvedProposals[proposal.id];
if (!resolved) return proposal;
return {
...proposal,
status: resolved.status,
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
};
});
/**
* The reply exists in two places once a turn finishes: the streamed text in
* `state`, and the stored copy that `onComplete` reloads into `history`.
* Whichever arrives is shown exactly once, and the stored copy wins because
* it sits in the right place in the timeline.
*/
const last = history[history.length - 1];
const historyEndsWithThisReply =
state.assistantText.length > 0 &&
last?.role === "assistant" &&
last.content === state.assistantText;
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
/**
* One ordered list rather than three stacked blocks.
*
* Tool calls happen before the reply that describes them, but once a turn
* ends its reply moves into `history`, which used to render above a separate
* tools block. The tool call appeared to jump below the answer it produced.
* Splicing the current turn's activity in ahead of its own reply keeps the
* order stable across that handover.
*/
const timeline: TimelineItem[] = [];
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
history.forEach((entry, index) => {
if (index === trailingReply) return;
timeline.push({
kind: "message",
key: `history-${index}`,
role: entry.role,
content: entry.content,
});
});
for (const tool of state.tools) {
timeline.push({ kind: "tool", key: tool.id, tool });
}
if (trailingReply >= 0) {
timeline.push({
kind: "message",
key: `history-${trailingReply}`,
role: "assistant",
content: history[trailingReply].content,
});
} else if (streamingReply) {
timeline.push({
kind: "message",
key: "streaming",
role: "assistant",
content: streamingReply,
});
}
for (const proposal of proposals) {
timeline.push({
kind: "proposal",
key: `proposal-${proposal.id}`,
proposal,
});
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="animate-spin text-muted-foreground" size={20} />
</div>
);
}
return (
<div className="flex h-full flex-col">
{/*
The panel also lives in the narrow sidebar, so the title and the
provider picker get their own rows rather than competing for one line.
*/}
<div className="border-b border-border">
<div className="flex items-center gap-2 px-3 py-2">
<Sparkles size={16} className="shrink-0" />
<span className="truncate text-sm font-medium">{t("ai.title")}</span>
<a
href="https://docs.termix.site/features/ai/overview"
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<Button
size="sm"
variant="ghost"
className="shrink-0"
onClick={() => setShowSettings((value) => !value)}
aria-label={t("ai.providerSettings")}
>
<Settings2 size={14} />
</Button>
</div>
{providers.length > 0 && (
<div className="flex items-center gap-2 px-3 pb-2">
<Select
value={providerId ? String(providerId) : undefined}
onValueChange={(value) => setProviderId(Number(value))}
>
<SelectTrigger className="h-7 min-w-0 flex-1 rounded-none text-xs">
<SelectValue placeholder={t("ai.selectProvider")} />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={String(provider.id)}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{showSettings && (
<div className="border-b border-border p-3">
<AiProviderSettings
providers={providers}
onChanged={loadProviders}
// Adding the first provider is the reason the form opened, so it
// closes once that is done and the chat becomes usable.
onAdded={() => setShowSettings(false)}
/>
</div>
)}
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-3">
{timeline.map((item) => {
if (item.kind === "message") {
return (
<AiMessage
key={item.key}
role={item.role}
content={item.content}
/>
);
}
if (item.kind === "tool") {
return (
<AiToolCall
key={item.key}
tool={item.tool}
streaming={state.streaming}
/>
);
}
return (
<ProposalCard
key={item.key}
proposal={item.proposal}
onResolved={handleProposalResolved}
/>
);
})}
{state.error && (
<div className="rounded-none border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{state.error}
</div>
)}
</div>
<div className="relative border-t border-border p-3">
{mentionMatches.length > 0 && (
<div className="absolute bottom-full left-3 right-3 z-10 max-h-56 overflow-y-auto border border-border bg-popover shadow-md">
{mentionMatches.map((item, index) => (
<button
key={`${item.kind}-${item.id}`}
type="button"
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs ${
index === mentionIndex ? "bg-muted" : ""
}`}
onMouseDown={(event) => {
// mousedown, so the textarea does not lose the caret first.
event.preventDefault();
insertMention(item);
}}
>
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
{mentionLabel(item.kind)}
</span>
<span className="truncate font-medium">{item.label}</span>
{item.detail && (
<span className="ml-auto shrink-0 truncate text-[10px] text-muted-foreground">
{item.detail}
</span>
)}
</button>
))}
</div>
)}
<Textarea
ref={textareaRef}
className="min-h-[64px] resize-none rounded-none text-sm"
value={input}
onChange={(event) => {
setInput(event.target.value);
updateMentionQuery(
event.target.value,
event.target.selectionStart ?? 0,
);
}}
onKeyDown={(event) => {
if (mentionMatches.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
setMentionIndex((i) => (i + 1) % mentionMatches.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMentionIndex(
(i) =>
(i - 1 + mentionMatches.length) % mentionMatches.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
insertMention(mentionMatches[mentionIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMention(null);
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
placeholder={t("ai.inputPlaceholder")}
disabled={!providerId}
/>
{/*
The hint gets its own line and wraps: sharing a row with the send
button left it truncated to an ellipsis in the sidebar, where the
panel is at its narrowest.
*/}
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
{t("ai.attachHint")}
</p>
<div className="mt-1.5 flex items-center justify-end gap-2">
{state.streaming ? (
<Button size="sm" variant="outline" onClick={stop}>
<Square size={14} />
{t("ai.stop")}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={!input.trim() || !providerId}
onClick={() => void handleSend()}
>
<Send size={14} />
{t("ai.send")}
</Button>
)}
</div>
</div>
</div>
);
}
-552
View File
@@ -1,552 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
createAiProvider,
deleteAiProvider,
getAiProviderModels,
probeAiModels,
updateAiProvider,
type AiProvider,
type AiProviderType,
} from "@/api/ai-api";
const PROVIDER_TYPES: Array<{
value: AiProviderType;
labelKey: string;
needsBaseUrl: boolean;
needsApiKey: boolean;
defaultBaseUrl?: string;
}> = [
{
value: "ollama",
labelKey: "ai.providerOllama",
needsBaseUrl: true,
needsApiKey: false,
defaultBaseUrl: "http://localhost:11434",
},
{
value: "anthropic",
labelKey: "ai.providerAnthropic",
needsBaseUrl: false,
needsApiKey: true,
},
{
value: "openai",
labelKey: "ai.providerOpenai",
needsBaseUrl: false,
needsApiKey: true,
},
{
value: "gemini",
labelKey: "ai.providerGemini",
needsBaseUrl: false,
needsApiKey: true,
},
{
value: "openai_compatible",
labelKey: "ai.providerOpenaiCompatible",
needsBaseUrl: true,
needsApiKey: false,
},
];
interface AiProviderSettingsProps {
providers: AiProvider[];
/** selectId names a provider that should become the active one. */
onChanged: (selectId?: number) => void;
onAdded?: () => void;
}
function AiProviderEditForm({
provider,
onSaved,
onCancel,
}: {
provider: AiProvider;
onSaved: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [label, setLabel] = useState(provider.label);
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
const [models, setModels] = useState<string[]>([]);
const [customModel, setCustomModel] = useState(false);
const [detecting, setDetecting] = useState(false);
const [detectWarning, setDetectWarning] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const detectModels = useCallback(async () => {
setDetecting(true);
setDetectWarning(null);
try {
const detected = await getAiProviderModels(provider.id);
setModels(detected);
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
} catch (error) {
setModels([]);
setCustomModel(true);
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
} finally {
setDetecting(false);
}
}, [provider.id, defaultModel, t]);
useEffect(() => {
void detectModels();
// The initial model value belongs to this provider. Subsequent edits must
// not trigger a provider model-list request on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider.id]);
async function handleSave() {
if (!label.trim()) {
toast.error(t("ai.labelRequired"));
return;
}
setSaving(true);
try {
await updateAiProvider(provider.id, {
label: label.trim(),
defaultModel: defaultModel.trim() || null,
});
toast.success(t("ai.providerUpdated"));
onSaved();
} catch (error) {
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
} finally {
setSaving(false);
}
}
return (
<div className="space-y-3 rounded-none border border-border p-3">
<div className="space-y-1.5">
<Label htmlFor={`ai-provider-label-${provider.id}`}>
{t("ai.providerLabel")}
</Label>
<Input
id={`ai-provider-label-${provider.id}`}
className="rounded-none"
value={label}
onChange={(event) => setLabel(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label
htmlFor={`ai-provider-model-${provider.id}`}
className="min-w-0 flex-1"
>
{t("ai.defaultModel")}
</Label>
<button
type="button"
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
onClick={() => void detectModels()}
disabled={detecting}
>
{detecting ? (
<Loader2 size={11} className="animate-spin" />
) : (
<RefreshCw size={11} />
)}
{t("ai.modelRefresh")}
</button>
</div>
{models.length > 0 && !customModel ? (
<Select
value={defaultModel || undefined}
onValueChange={(value) => {
if (value === "__custom__") {
setCustomModel(true);
setDefaultModel("");
return;
}
setDefaultModel(value);
}}
>
<SelectTrigger
id={`ai-provider-model-${provider.id}`}
className="rounded-none"
>
<SelectValue placeholder={t("ai.modelPlaceholder")} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={`ai-provider-model-${provider.id}`}
className="rounded-none"
value={defaultModel}
onChange={(event) => setDefaultModel(event.target.value)}
placeholder={t("ai.defaultModelPlaceholder")}
/>
)}
{detectWarning && (
<p className="text-[11px] leading-snug text-destructive">
{detectWarning}
</p>
)}
</div>
<div className="flex gap-2">
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
{saving && <Loader2 size={14} className="animate-spin" />}
{t("ai.save")}
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={onCancel}
>
{t("ai.cancel")}
</Button>
</div>
</div>
);
}
export function AiProviderSettings({
providers,
onChanged,
onAdded,
}: AiProviderSettingsProps) {
const { t } = useTranslation();
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
const [label, setLabel] = useState("");
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
const [apiKey, setApiKey] = useState("");
const [defaultModel, setDefaultModel] = useState("");
const [models, setModels] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false);
const [detectWarning, setDetectWarning] = useState<string | null>(null);
const [customModel, setCustomModel] = useState(false);
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
useEffect(() => {
setBaseUrl(spec.defaultBaseUrl ?? "");
setApiKey("");
setModels([]);
setDefaultModel("");
setCustomModel(false);
setDetectWarning(null);
}, [providerType, spec.defaultBaseUrl]);
/**
* Fetches the provider's own model list so nobody has to go and look model
* names up. Falls back to a curated list when the endpoint is unreachable,
* and a free-text field is always available for anything not listed.
*/
const detectModels = useCallback(async () => {
setDetecting(true);
setDetectWarning(null);
try {
const result = await probeAiModels({
providerType,
baseUrl: baseUrl.trim() || null,
apiKey: apiKey.trim() || null,
});
setModels(result.models);
if (result.source !== "live") {
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
}
// Pick the first suggestion so the field is never left empty.
setDefaultModel((current) => current || result.models[0] || "");
} catch {
setDetectWarning(t("ai.modelDetectFailed"));
} finally {
setDetecting(false);
}
}, [providerType, baseUrl, apiKey, t]);
// Detect as soon as the provider has enough detail to be reachable.
useEffect(() => {
if (!adding) return;
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
if (!ready) return;
const timer = setTimeout(() => void detectModels(), 400);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adding, providerType, baseUrl, apiKey]);
async function handleAdd() {
if (!label.trim()) {
toast.error(t("ai.labelRequired"));
return;
}
setSaving(true);
try {
const created = await createAiProvider({
providerType,
label: label.trim(),
baseUrl: baseUrl.trim() || null,
apiKey: apiKey.trim() || null,
defaultModel: defaultModel.trim() || null,
});
setAdding(false);
setLabel("");
setApiKey("");
setDefaultModel("");
onChanged(created.id);
onAdded?.();
} catch (error) {
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
try {
await deleteAiProvider(id);
onChanged();
} catch (error) {
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
}
}
return (
<div className="space-y-3">
{providers.map((provider) =>
editingId === provider.id ? (
<AiProviderEditForm
key={provider.id}
provider={provider}
onSaved={() => {
setEditingId(null);
onChanged(provider.id);
}}
onCancel={() => setEditingId(null)}
/>
) : (
<div
key={provider.id}
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{provider.label}
</div>
<div className="truncate text-xs text-muted-foreground">
{provider.providerType}
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setAdding(false);
setEditingId(provider.id);
}}
aria-label={t("ai.editProvider")}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(provider.id)}
aria-label={t("ai.removeProvider")}
>
<Trash2 size={14} />
</Button>
</div>
</div>
),
)}
{!adding && (
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingId(null);
setAdding(true);
}}
>
<Plus size={14} />
{t("ai.addProvider")}
</Button>
)}
{adding && (
<div className="space-y-3 rounded-none border border-border p-3">
<div className="space-y-1.5">
<Label>{t("ai.providerType")}</Label>
<Select
value={providerType}
onValueChange={(value) =>
setProviderType(value as AiProviderType)
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
{t(entry.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("ai.providerLabel")}</Label>
<Input
className="rounded-none"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={t("ai.providerLabelPlaceholder")}
/>
</div>
{spec.needsBaseUrl && (
<div className="space-y-1.5">
<Label>{t("ai.baseUrl")}</Label>
<Input
className="rounded-none"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="http://localhost:11434"
/>
<p className="text-xs text-muted-foreground">
{t("ai.privateEndpointHint")}
</p>
</div>
)}
{(spec.needsApiKey || providerType === "openai_compatible") && (
<div className="space-y-1.5">
<Label>{t("ai.apiKey")}</Label>
<Input
className="rounded-none"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="off"
/>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
<button
type="button"
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => void detectModels()}
disabled={detecting}
>
{detecting ? (
<Loader2 size={11} className="animate-spin" />
) : (
<RefreshCw size={11} />
)}
{t("ai.modelRefresh")}
</button>
</div>
{models.length > 0 && !customModel ? (
<Select
value={defaultModel || undefined}
onValueChange={(value) => {
if (value === "__custom__") {
setCustomModel(true);
setDefaultModel("");
return;
}
setDefaultModel(value);
}}
>
<SelectTrigger className="rounded-none">
<SelectValue placeholder={t("ai.modelPlaceholder")} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
{/* Anything the provider did not list is still reachable. */}
<SelectItem value="__custom__">
{t("ai.modelCustom")}
</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="rounded-none"
value={defaultModel}
onChange={(event) => setDefaultModel(event.target.value)}
placeholder={t("ai.defaultModelPlaceholder")}
/>
)}
{detectWarning && (
<p className="text-[11px] leading-snug text-muted-foreground">
{detectWarning}
</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={handleAdd}
>
{saving && <Loader2 size={14} className="animate-spin" />}
{t("ai.save")}
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={() => setAdding(false)}
>
{t("ai.cancel")}
</Button>
</div>
</div>
)}
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Loader2, Wrench } from "lucide-react";
import type { ToolActivity } from "./use-ai-stream";
import { toolLabel } from "./labels";
/**
* Every tool call is shown, so the user can always see what the assistant
* looked at rather than having to trust the summary.
*/
export function AiToolCall({
tool,
streaming = false,
}: {
tool: ToolActivity;
/** False once the turn has ended, whatever ended it. */
streaming?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// A tool can only still be running while the turn is. An aborted or failed
// stream leaves calls without results, and treating those as pending left a
// spinner running forever after the conversation had finished.
const pending = !("result" in tool) && streaming;
return (
<div className="rounded-none border border-border bg-muted/50 text-xs">
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
onClick={() => setOpen((value) => !value)}
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{pending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Wrench size={12} />
)}
<span className="truncate">{toolLabel(tool.name)}</span>
{pending && (
<span className="text-muted-foreground">{t("ai.running")}</span>
)}
</button>
{open && (
<div className="space-y-2 border-t border-border px-2 py-2">
{/* The header reads as an action, so the actual tool id lives here. */}
<div className="font-mono text-[10px] text-muted-foreground">
{tool.name}
</div>
{Object.keys(tool.arguments).length > 0 && (
<div>
<div className="mb-1 text-muted-foreground">
{t("ai.toolArguments")}
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono">
{JSON.stringify(tool.arguments, null, 2)}
</pre>
</div>
)}
{/*
Gated on the result existing, not on !pending: an interrupted turn
leaves a call with no result at all, which would otherwise print
"undefined" under a Result heading.
*/}
{"result" in tool && (
<div>
<div className="mb-1 text-muted-foreground">
{t("ai.toolResult")}
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono">
{JSON.stringify(tool.result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
-212
View File
@@ -1,212 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import {
applyAiProposal,
rejectAiProposal,
type AiProposal,
} from "@/api/ai-api";
import { fieldLabel, toolLabel } from "./labels";
interface ProposalCardProps {
proposal: AiProposal;
onResolved: (
id: number,
status: "applied" | "rejected",
resultSummary?: string,
) => void;
}
function isDestructive(kind: string): boolean {
return kind.includes("delete");
}
/**
* Fields shown in their own block rather than the generic row list, so the
* card does not print the same command twice.
*/
const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]);
/** Renders the payload as readable field rows rather than raw JSON. */
function fieldRows(payload: Record<string, unknown>): Array<[string, string]> {
const rows: Array<[string, string]> = [];
const push = (key: string, value: unknown) => {
if (value === null || value === undefined || value === "") return;
if (PROMOTED_FIELDS.has(key)) return;
const text =
typeof value === "object" ? JSON.stringify(value) : String(value);
rows.push([key, text]);
};
for (const [key, value] of Object.entries(payload)) {
if (key === "changes" && value && typeof value === "object") {
for (const [childKey, childValue] of Object.entries(
value as Record<string, unknown>,
)) {
push(childKey, childValue);
}
continue;
}
push(key, value);
}
return rows;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function ProposalCard({ proposal, onResolved }: ProposalCardProps) {
const { t } = useTranslation();
const [busy, setBusy] = useState<"apply" | "reject" | null>(null);
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(proposal.payload);
} catch {
payload = {};
}
const rows = fieldRows(payload);
const destructive = isDestructive(proposal.kind);
const resolved = proposal.status !== "pending";
const command = asText(payload.command);
const explanation = asText(payload.explanation) ?? asText(payload.reason);
// The stored summary repeats the command for run_command proposals, so a
// short action label is used instead of printing it twice.
const title =
command !== null
? t("ai.runCommandTitle")
: (proposal.summary ?? toolLabel(proposal.kind));
async function handleApply() {
setBusy("apply");
try {
const result = await applyAiProposal(proposal.id);
// The result goes into the card, not a toast: command output can be
// hundreds of lines, which covered the screen.
onResolved(proposal.id, "applied", result.summary);
} catch (error) {
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
} finally {
setBusy(null);
}
}
async function handleReject() {
setBusy("reject");
try {
await rejectAiProposal(proposal.id);
onResolved(proposal.id, "rejected");
} catch (error) {
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
} finally {
setBusy(null);
}
}
return (
<div className="rounded-none border border-border bg-muted p-2.5">
<div className="flex items-center gap-1.5">
{destructive && (
<TriangleAlert size={13} className="shrink-0 text-destructive" />
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{title}
</span>
{resolved && (
<span className="shrink-0 border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
{proposal.status === "applied"
? t("ai.statusApplied")
: t("ai.statusRejected")}
</span>
)}
</div>
{/*
A command is the payload, not a field of it, so it gets its own block
rather than a label column. In the sidebar a fixed label gutter left
barely any room for the command itself.
*/}
{command && (
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
{command}
</pre>
)}
{explanation && (
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
{explanation}
</p>
)}
{rows.length > 0 && (
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
{rows.map(([key, value]) => (
// Stacked, not a two-column row: the value is what matters and it
// needs the full width to stay readable.
<div key={key} className="min-w-0">
<span className="text-[10px] text-muted-foreground">
{fieldLabel(key)}
</span>
<div className="break-all font-mono text-[11px] leading-snug">
{value}
</div>
</div>
))}
</div>
)}
{proposal.resultSummary && (
// Command output comes back here, so it keeps the monospace treatment.
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug text-muted-foreground">
{proposal.resultSummary}
</pre>
)}
{!resolved && (
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
className={`h-7 flex-1 text-xs ${
destructive
? ""
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
}`}
variant={destructive ? "destructive" : "outline"}
disabled={busy !== null}
onClick={handleApply}
>
{busy === "apply" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
{t("ai.approve")}
</Button>
<Button
size="sm"
className="h-7 flex-1 text-xs"
variant="outline"
disabled={busy !== null}
onClick={handleReject}
>
{busy === "reject" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<X size={13} />
)}
{t("ai.reject")}
</Button>
</div>
)}
</div>
);
}
-84
View File
@@ -1,84 +0,0 @@
/**
* Turns the internal identifiers the assistant works in into text a user can
* read. Tool names, proposal kinds and payload keys all surface in the panel,
* and raw ids like "propose_create_automation" or "thresholddurationseconds"
* are not something to show anyone.
*/
/** What each tool is doing, phrased as an action rather than a function name. */
const TOOL_LABELS: Record<string, string> = {
list_hosts: "Reading hosts",
get_host: "Reading host",
list_fleets: "Reading fleets",
list_snippets: "Reading snippets",
list_automations: "Reading automations",
get_automation: "Reading automation",
list_workspaces: "Reading workspaces",
list_notification_channels: "Reading notification channels",
list_homepage_items: "Reading homepage items",
get_command_history: "Reading command history",
get_network_topology: "Reading network topology",
propose_create_host: "Add host",
propose_update_host: "Update host",
propose_delete_host: "Delete host",
propose_create_snippet: "Create snippet",
propose_update_snippet: "Update snippet",
propose_delete_snippet: "Delete snippet",
propose_create_automation: "Create automation",
propose_create_fleet: "Create fleet",
propose_run_command: "Run command",
};
/** Readable names for the payload keys the propose tools emit. */
const FIELD_LABELS: Record<string, string> = {
hostId: "Host",
hostIds: "Hosts",
snippetId: "Snippet",
automationId: "Automation",
name: "Name",
ip: "Address",
port: "Port",
username: "Username",
folder: "Folder",
tags: "Tags",
content: "Command",
description: "Description",
definition: "Definition",
enabled: "Enabled",
triggerType: "Trigger",
thresholdValue: "Threshold",
thresholdDurationSeconds: "Duration (seconds)",
cooldownMinutes: "Cooldown (minutes)",
};
const MENTION_LABELS: Record<string, string> = {
host: "Host",
snippet: "Snippet",
automation: "Automation",
};
/**
* Splits snake_case and camelCase into words and capitalises the first.
* Used whenever an identifier has no explicit label, so something added later
* still reads as words rather than one run-on token.
*/
export function humanize(value: string): string {
const spaced = value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.trim();
if (!spaced) return value;
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
}
export function toolLabel(name: string): string {
return TOOL_LABELS[name] ?? humanize(name);
}
export function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? humanize(key);
}
export function mentionLabel(kind: string): string {
return MENTION_LABELS[kind] ?? humanize(kind);
}
-220
View File
@@ -1,220 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useRef, useState } from "react";
import { authApi } from "@/main-axios";
import type { AiProposal } from "@/api/ai-api";
/**
* Reuses whatever base authApi resolved to, so the stream follows the same
* dev proxy, Electron localhost and reverse-proxy base path rules as every
* other call instead of hardcoding an origin.
*/
function streamUrl(): string {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
return `${base}/ai/chat/stream`;
}
/**
* Drives the chat stream.
*
* EventSource cannot POST, and the request carries a message body, so this
* reads the SSE frames off a fetch response by hand.
*/
export interface ToolActivity {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
}
export interface StreamState {
streaming: boolean;
assistantText: string;
tools: ToolActivity[];
proposals: AiProposal[];
error: string | null;
conversationId: number | null;
}
const INITIAL: StreamState = {
streaming: false,
assistantText: "",
tools: [],
proposals: [],
error: null,
conversationId: null,
};
export function useAiStream() {
const [state, setState] = useState<StreamState>(INITIAL);
const abortRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
setState(INITIAL);
}, []);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setState((prev) => ({ ...prev, streaming: false }));
}, []);
const send = useCallback(
async (input: {
message: string;
providerId: number;
model?: string;
conversationId?: number | null;
activeTab?: string | null;
onComplete?: (conversationId: number | null, reply: string) => void;
}) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setState({
streaming: true,
assistantText: "",
tools: [],
proposals: [],
error: null,
conversationId: input.conversationId ?? null,
});
let conversationId = input.conversationId ?? null;
let replyText = "";
let toolSequence = 0;
try {
const response = await fetch(streamUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
message: input.message,
providerId: input.providerId,
model: input.model,
conversationId: input.conversationId ?? undefined,
activeTab: input.activeTab ?? undefined,
}),
});
if (!response.ok) {
let message = "The assistant could not be reached";
try {
message = (await response.json()).error ?? message;
} catch {
// Keep the generic message.
}
setState((prev) => ({ ...prev, streaming: false, error: message }));
return;
}
const reader = response.body?.getReader();
if (!reader) {
setState((prev) => ({
...prev,
streaming: false,
error: "The assistant returned an empty response",
}));
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
if (!line.startsWith("data:")) continue;
let event: any;
try {
event = JSON.parse(line.slice(5).trim());
} catch {
continue;
}
if (event.type === "conversation") {
conversationId = event.conversationId;
setState((prev) => ({
...prev,
conversationId: event.conversationId,
}));
} else if (event.type === "token") {
replyText += event.text;
setState((prev) => ({
...prev,
assistantText: prev.assistantText + event.text,
}));
} else if (event.type === "tool_call") {
// The id is assigned here rather than inside the updater: React
// may run an updater more than once, and deriving the id from
// prev.tools.length appended a duplicate entry that no result
// could ever match, leaving it stuck on "running".
const activityId = `tool-${toolSequence++}`;
setState((prev) => ({
...prev,
tools: [
...prev.tools,
{
id: activityId,
name: event.name,
arguments: event.arguments ?? {},
},
],
}));
} else if (event.type === "tool_result") {
setState((prev) => {
const tools = [...prev.tools];
// Attach to the most recent call of this tool awaiting a result.
for (let i = tools.length - 1; i >= 0; i -= 1) {
if (tools[i].name === event.name && !("result" in tools[i])) {
tools[i] = { ...tools[i], result: event.result };
break;
}
}
return { ...prev, tools };
});
} else if (event.type === "proposal") {
setState((prev) => ({
...prev,
proposals: [...prev.proposals, event.proposal],
}));
} else if (event.type === "error") {
setState((prev) => ({ ...prev, error: event.message }));
}
}
}
setState((prev) => ({ ...prev, streaming: false }));
input.onComplete?.(conversationId, replyText);
} catch (error) {
if (controller.signal.aborted) {
setState((prev) => ({ ...prev, streaming: false }));
return;
}
setState((prev) => ({
...prev,
streaming: false,
error: getErrorMessage(error, "The assistant stopped unexpectedly"),
}));
} finally {
abortRef.current = null;
}
},
[],
);
return { state, send, stop, reset, setState };
}
-108
View File
@@ -1,108 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getSnippets } from "@/api/snippets-api";
import { listAutomations } from "../../../../plugins/automations/src/frontend/automations-api";
/**
* Backs the @-mention picker in the composer.
*
* A mention is only ever a name the assistant reads in the message text; it
* does not grant access to anything. The assistant still has to call a read
* tool to see the referenced item, so every access stays visible in the
* transcript rather than being silently attached to the prompt.
*/
export type MentionKind = "host" | "snippet" | "automation";
export interface MentionItem {
kind: MentionKind;
id: number;
label: string;
detail?: string;
}
/** Extracts the "@partial" the caret currently sits in, if any. */
export function activeMentionQuery(
value: string,
caret: number,
): { query: string; start: number } | null {
const before = value.slice(0, caret);
const at = before.lastIndexOf("@");
if (at === -1) return null;
// Only trigger at a word boundary, so an email address does not open it.
if (at > 0 && !/\s/.test(before[at - 1])) return null;
const query = before.slice(at + 1);
if (/\s/.test(query)) return null;
return { query, start: at };
}
export function useMentions(enabled: boolean) {
const [items, setItems] = useState<MentionItem[]>([]);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
(async () => {
const [hosts, snippets, automations] = await Promise.allSettled([
getSSHHosts(),
getSnippets(),
listAutomations(),
]);
if (cancelled) return;
const collected: MentionItem[] = [];
if (hosts.status === "fulfilled") {
for (const host of (hosts.value ?? []) as any[]) {
collected.push({
kind: "host",
id: host.id,
label: host.name || host.ip,
detail: host.ip,
});
}
}
if (snippets.status === "fulfilled") {
for (const snippet of (snippets.value ?? []) as any[]) {
collected.push({
kind: "snippet",
id: snippet.id,
label: snippet.name,
});
}
}
if (automations.status === "fulfilled") {
for (const automation of (automations.value ?? []) as any[]) {
collected.push({
kind: "automation",
id: automation.id,
label: automation.name,
});
}
}
setItems(collected);
})();
return () => {
cancelled = true;
};
}, [enabled]);
const search = useCallback(
(query: string): MentionItem[] => {
const needle = query.trim().toLowerCase();
const matches = needle
? items.filter((item) => item.label.toLowerCase().includes(needle))
: items;
return matches.slice(0, 8);
},
[items],
);
return useMemo(() => ({ items, search }), [items, search]);
}
+4 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay";
import {
GuestTerminalView,
type SessionParticipantInfo,
@@ -100,13 +100,10 @@ export default function CollabGuestView() {
}
/>
) : stage.connectParams?.token ? (
<GuacamoleDisplay
<RemoteDisplay
key={stage.shareId}
connectionConfig={{
token: stage.connectParams.token,
protocol: stage.protocol,
type: stage.protocol,
}}
token={stage.connectParams.token}
protocol={stage.protocol}
isVisible
/>
) : null}
+13 -17
View File
@@ -33,8 +33,10 @@ import { Input } from "@/components/input";
import { CollabMembersSidebar } from "./CollabMembersSidebar";
import { Terminal } from "@/features/terminal/Terminal";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/src/frontend/guacamole-api";
import {
RemoteDisplay,
createRemoteSessionToken,
} from "@/features/remote-display/RemoteDisplay";
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
import { getRoles } from "@/api/rbac-api";
import type { Role } from "@/main-axios";
@@ -341,7 +343,7 @@ export function CollabRoomTab({
setDraft({ protocol, host });
return;
}
const response = await getGuacamoleTokenFromHost(
const response = await createRemoteSessionToken(
Number(host.id),
await resolveConnectionOrigin({
connectionType: protocol,
@@ -349,7 +351,7 @@ export function CollabRoomTab({
}),
protocol,
);
if (!response.guacamoleConnectionId) {
if (!response?.connectionId) {
toast.error(t("collab.stageLoading"));
return;
}
@@ -357,7 +359,7 @@ export function CollabRoomTab({
protocol,
host,
token: response.token,
guacamoleConnectionId: response.guacamoleConnectionId,
guacamoleConnectionId: response.connectionId,
});
} catch (error) {
toast.error(getErrorMessage(error));
@@ -689,12 +691,9 @@ export function CollabRoomTab({
/>
</CommandHistoryProvider>
) : (
<GuacamoleDisplay
connectionConfig={{
token: draft.token,
protocol: draft.protocol,
type: draft.protocol,
}}
<RemoteDisplay
token={draft.token}
protocol={draft.protocol}
isVisible={isVisible}
onConnect={() =>
void registerStage(
@@ -735,13 +734,10 @@ export function CollabRoomTab({
/>
</CommandHistoryProvider>
) : stage.connectParams?.token ? (
<GuacamoleDisplay
<RemoteDisplay
key={stage.connectParams.token}
connectionConfig={{
token: stage.connectParams.token,
protocol: stage.protocol,
type: stage.protocol,
}}
token={stage.connectParams.token}
protocol={stage.protocol}
isVisible={isVisible}
/>
) : (
@@ -37,7 +37,6 @@ import "./widgets/FolderWidget";
import "./widgets/WeatherWidget";
import "./widgets/IframeWidget";
import "./widgets/RssFeedWidget";
import "./widgets/MetricsChartWidget";
import "./widgets/HostGridWidget";
import "./widgets/PingStatusWidget";
import "./widgets/RecentActivityWidget";
@@ -46,7 +45,6 @@ import "./widgets/SystemOverviewWidget";
import "./widgets/SshTerminalWidget";
import "./widgets/QuickConnectWidget";
import "./widgets/FileManagerWidget";
import "./widgets/DockerWidget";
import "./widgets/TunnelWidget";
import "./widgets/CalendarWidget";
import "./widgets/CountdownWidget";
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllWidgetTypes } from "../widgets/WidgetRegistry";
import { useWidgetTypes } from "../widgets/WidgetRegistry";
import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types";
interface AddWidgetMenuProps {
@@ -16,7 +16,7 @@ const MARGIN = 8;
export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const allTypes = getAllWidgetTypes();
const allTypes = useWidgetTypes();
const [pos, setPos] = useState({ left: state.screenX, top: state.screenY });
const [selectedCat, setSelectedCat] = useState<
(typeof CATEGORY_ORDER)[number]
@@ -1,18 +0,0 @@
import type {
DockerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "./SingleHostEditForm";
export function DockerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerWidgetConfig>) {
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableDocker)}
/>
);
}
@@ -1,111 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
MetricsChartConfig,
MetricsChartMetric,
MetricsChartRange,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { Select2 } from "@/components/select2";
const METRICS: { id: MetricsChartMetric; label: string }[] = [
{ id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" },
{ id: "net_rx", label: "RX MB/s" },
{ id: "net_tx", label: "TX MB/s" },
];
const RANGES: { id: MetricsChartRange; label: string }[] = [
{ id: "15m", label: "15m" },
{ id: "1h", label: "1h" },
{ id: "6h", label: "6h" },
{ id: "24h", label: "24h" },
];
export function MetricsChartEditForm({
config,
onChange,
}: WidgetEditFormProps<MetricsChartConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
>
<option value={0}>{t("homepage.selectHost")}</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.chartMetric")}
</label>
<div className="flex flex-wrap gap-1">
{METRICS.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onChange({ ...config, metric: m.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{m.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.metricRange")}
</label>
<div className="flex gap-1">
{RANGES.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onChange({ ...config, range: r.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{r.label}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showCurrentValue}
onChange={(e) =>
onChange({ ...config, showCurrentValue: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showCurrentValue")}
</label>
</div>
);
}
@@ -7,17 +7,8 @@ import type {
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
const ALL_TYPES: QuickConnectType[] = [
"terminal",
"files",
"docker",
"tunnel",
"host-metrics",
"rdp",
"vnc",
"telnet",
];
import { useHostActions } from "@/sidebar/host-contributions";
import { quickConnectTargets } from "../quick-connect-targets";
export function QuickConnectEditForm({
config,
@@ -25,6 +16,7 @@ export function QuickConnectEditForm({
}: WidgetEditFormProps<QuickConnectConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const targets = quickConnectTargets(useHostActions());
useEffect(() => {
getSSHHosts()
@@ -84,7 +76,7 @@ export function QuickConnectEditForm({
{t("homepage.connectionTypes")}
</label>
<div className="grid grid-cols-2 gap-1">
{ALL_TYPES.map((type) => (
{targets.map(({ type, labelKey }) => (
<label
key={type}
className="flex items-center gap-2 text-xs cursor-pointer"
@@ -95,7 +87,7 @@ export function QuickConnectEditForm({
onChange={() => toggleType(type)}
className="accent-accent-brand"
/>
{t(`homepage.connType_${type}`)}
{t(labelKey, type)}
</label>
))}
</div>
@@ -1,21 +1,12 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { activityFilterTypes } from "../quick-connect-targets";
import type {
RecentActivityConfig,
ActivityType,
WidgetEditFormProps,
} from "@/types/homepage-types";
const ALL_TYPES: ActivityType[] = [
"terminal",
"file_manager",
"docker",
"tunnel",
"rdp",
"vnc",
"telnet",
];
export function RecentActivityEditForm({
config,
onChange,
@@ -55,7 +46,7 @@ export function RecentActivityEditForm({
{t("homepage.filterActivityTypes")}
</label>
<div className="flex flex-wrap gap-1">
{ALL_TYPES.map((type) => (
{activityFilterTypes().map((type) => (
<button
key={type}
type="button"
@@ -1,4 +1,5 @@
import { useState } from "react";
import { getWidgetType } from "../widgets/WidgetRegistry";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -20,7 +21,6 @@ import type {
WeatherConfig,
IframeConfig,
RssFeedConfig,
MetricsChartConfig,
HostGridConfig,
PingStatusConfig,
RecentActivityConfig,
@@ -29,7 +29,6 @@ import type {
SshTerminalConfig,
QuickConnectConfig,
FileManagerWidgetConfig,
DockerWidgetConfig,
TunnelWidgetConfig,
CalendarConfig,
CountdownConfig,
@@ -52,7 +51,6 @@ import { HostStatusEditForm } from "./HostStatusEditForm";
import { WeatherEditForm } from "./WeatherEditForm";
import { IframeEditForm } from "./IframeEditForm";
import { RssFeedEditForm } from "./RssFeedEditForm";
import { MetricsChartEditForm } from "./MetricsChartEditForm";
import { HostGridEditForm } from "./HostGridEditForm";
import { PingStatusEditForm } from "./PingStatusEditForm";
import { RecentActivityEditForm } from "./RecentActivityEditForm";
@@ -61,7 +59,6 @@ import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
import { SshTerminalEditForm } from "./SshTerminalEditForm";
import { QuickConnectEditForm } from "./QuickConnectEditForm";
import { FileManagerWidgetEditForm } from "./FileManagerWidgetEditForm";
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
import { TunnelWidgetEditForm } from "./TunnelWidgetEditForm";
import { CalendarEditForm } from "./CalendarEditForm";
import { CountdownEditForm } from "./CountdownEditForm";
@@ -181,13 +178,6 @@ export function WidgetEditDialog({
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "metrics_chart":
return (
<MetricsChartEditForm
config={config as unknown as MetricsChartConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "host_grid":
return (
<HostGridEditForm
@@ -244,13 +234,6 @@ export function WidgetEditDialog({
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "docker_widget":
return (
<DockerWidgetEditForm
config={config as unknown as DockerWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "tunnel_widget":
return (
<TunnelWidgetEditForm
@@ -335,8 +318,13 @@ export function WidgetEditDialog({
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
default:
return null;
default: {
// Plugin widgets bring their own form.
const EditForm = getWidgetType(widget.typeId)?.editFormComponent;
return EditForm ? (
<EditForm config={config} onChange={setConfig} />
) : null;
}
}
};
@@ -0,0 +1,64 @@
import type { ComponentType } from "react";
import { FolderSearch, Network } from "lucide-react";
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host } from "@/types/ui-types";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import type { HostActionDef } from "@/sidebar/host-contributions";
import { listTabTypes } from "@/shell/tab-registry";
export interface QuickConnectTarget {
/** Tab type, and the value stored in a widget's config. */
type: string;
icon: ComponentType<{ size?: number | string; className?: string }>;
/** i18n key, or a literal label from a plugin action. */
labelKey: string;
enabled: (host: SSHHostWithStatus) => boolean;
}
/** What a quick connect widget can open: core tools plus plugin actions. */
export function quickConnectTargets(
actions: HostActionDef[],
): QuickConnectTarget[] {
const core: QuickConnectTarget[] = [
{
type: "files",
icon: FolderSearch,
labelKey: "homepage.connType_files",
enabled: (h) => !!(h.enableSsh && h.enableFileManager),
},
{
type: "tunnel",
icon: Network,
labelKey: "homepage.connType_tunnel",
enabled: (h) => !!(h.enableSsh && h.enableTunnel),
},
];
const seen = new Set(core.map((target) => target.type));
const fromPlugins: QuickConnectTarget[] = [];
for (const action of actions) {
if (!action.tabType || seen.has(action.tabType)) continue;
seen.add(action.tabType);
fromPlugins.push({
type: action.tabType,
icon: action.icon,
labelKey: action.titleKey,
enabled: (h) => {
try {
return action.when(sshHostToHost(h) as Host);
} catch {
return false;
}
},
});
}
return [...fromPlugins, ...core];
}
/** Recent-activity types a filter can pick: core ones plus plugin tabs'. */
export function activityFilterTypes(): string[] {
const types = new Set(["file_manager", "tunnel"]);
for (const def of listTabTypes()) {
for (const type of def.activityTypes ?? []) types.add(type);
}
return [...types];
}
@@ -1,94 +0,0 @@
import { useState, useEffect } from "react";
import { Box } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
DockerWidgetConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { DockerManager } from "../../../../../plugins/docker/src/frontend/DockerManager";
import type { SSHHost } from "@/types/index";
import { WidgetTitle } from "./WidgetTitle";
function DockerWidget({
widget,
config,
}: WidgetComponentProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!config.hostId) {
setLoading(false);
return;
}
getSSHHosts()
.then((hosts) => {
const found = hosts.find(
(h) => h.id === config.hostId && h.enableDocker,
);
setHost(found ?? null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [config.hostId]);
if (!config.hostId) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Box size={20} />
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.loading")}
</div>
);
}
if (!host) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.widgetNoHostSelected")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<DockerManager
hostConfig={host as unknown as SSHHost}
title={host.name || host.ip}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
</div>
</div>
);
}
registerWidget<DockerWidgetConfig>({
id: "docker_widget",
name: "Docker Manager",
description: "Embedded Docker container manager for a configured host",
category: "system",
icon: <Box size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
component: DockerWidget,
});
@@ -1,254 +0,0 @@
import { useEffect, useState, useRef } from "react";
import { TrendingUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
MetricsChartConfig,
MetricsChartMetric,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import {
getMetricsHistory,
type MetricsHistoryRow,
} from "@/api/host-metrics-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function getMetricValue(
row: MetricsHistoryRow,
metric: MetricsChartMetric,
): number | null {
switch (metric) {
case "cpu":
return row.cpu_percent;
case "memory":
return row.mem_percent;
case "disk":
return row.disk_percent;
case "net_rx":
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
case "net_tx":
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
}
}
function getMetricLabel(
metric: MetricsChartMetric,
t: (k: string) => string,
): string {
switch (metric) {
case "cpu":
return t("homepage.metricCpu");
case "memory":
return t("homepage.metricMemory");
case "disk":
return t("homepage.metricDisk");
case "net_rx":
return "RX MB/s";
case "net_tx":
return "TX MB/s";
}
}
function getMetricUnit(metric: MetricsChartMetric): string {
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
return "%";
}
interface SparklineProps {
values: number[];
width: number;
height: number;
color: string;
}
function Sparkline({ values, width, height, color }: SparklineProps) {
if (values.length < 2) return null;
const max = Math.max(...values, 1);
const min = 0;
const range = max - min;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * width;
const y = height - ((v - min) / range) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className="overflow-visible"
>
<defs>
<linearGradient
id={`mg-${color.replace("#", "")}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
<polyline
points={pts.join(" ")}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function MetricsChartWidget({
widget,
config,
}: WidgetComponentProps<MetricsChartConfig>) {
const { t } = useTranslation();
const { hostId, metric, range, showCurrentValue } = config;
const [hostName, setHostName] = useState<string | null>(null);
const [values, setValues] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 0, h: 0 });
useEffect(() => {
if (!hostId) return;
getSSHHosts()
.then((hosts) => {
const h = hosts.find((x) => x.id === hostId);
if (h) setHostName(h.name);
})
.catch(() => {});
}, [hostId]);
useEffect(() => {
if (!containerRef.current) return;
const obs = new ResizeObserver((entries) => {
const e = entries[0]?.contentRect;
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
});
obs.observe(containerRef.current);
return () => obs.disconnect();
}, []);
const fetchData = () => {
if (!hostId) return;
getMetricsHistory(hostId, { range })
.then((res) => {
const vals = res.rows
.map((r) => getMetricValue(r, metric))
.filter((v): v is number => v != null);
setValues(vals);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 60_000);
}, [hostId, metric, range]);
const accent = getAccentColor();
const current = values.length > 0 ? values[values.length - 1] : null;
const unit = getMetricUnit(metric);
const label = getMetricLabel(metric, t);
if (!hostId) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noHostSelected")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
<div className="flex items-center justify-between gap-1 shrink-0">
<div className="flex flex-col min-w-0">
<span className="text-[10px] font-semibold text-foreground truncate">
{hostName ?? `Host #${hostId}`}
</span>
<span className="text-[9px] text-muted-foreground">
{label} · {range}
</span>
</div>
{showCurrentValue && current != null && (
<span
className="text-sm font-bold tabular-nums shrink-0"
style={{ color: accent }}
>
{metric === "net_rx" || metric === "net_tx"
? current.toFixed(2)
: Math.round(current)}
{unit}
</span>
)}
</div>
<div ref={containerRef} className="flex-1 min-h-0 relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
{t("homepage.loading")}
</div>
) : values.length < 2 ? (
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
{t("homepage.noData")}
</div>
) : (
dims.w > 0 &&
dims.h > 0 && (
<Sparkline
values={values}
width={dims.w}
height={dims.h}
color={accent}
/>
)
)}
</div>
</div>
</div>
);
}
registerWidget<MetricsChartConfig>({
id: "metrics_chart",
name: "Metrics Chart",
description: "Historical CPU, memory, disk, or network chart for a host",
category: "monitoring",
icon: <TrendingUp size={14} />,
defaultConfig: {
hostId: 0,
metric: "cpu",
range: "1h",
showCurrentValue: true,
},
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
component: MetricsChartWidget,
});
export { MetricsChartWidget };
@@ -1,15 +1,5 @@
import { useState, useEffect } from "react";
import {
Terminal,
FolderSearch,
Box,
Network,
Server,
Monitor,
Tv,
Phone,
Zap,
} from "lucide-react";
import { Zap } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
@@ -22,30 +12,8 @@ import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import type { TabType } from "@/types/ui-types";
import { WidgetTitle } from "./WidgetTitle";
const TYPE_ICONS: Record<QuickConnectType, React.ReactNode> = {
terminal: <Terminal size={12} />,
files: <FolderSearch size={12} />,
docker: <Box size={12} />,
tunnel: <Network size={12} />,
"host-metrics": <Server size={12} />,
rdp: <Monitor size={12} />,
vnc: <Tv size={12} />,
telnet: <Phone size={12} />,
};
type EnableCheck = (host: SSHHostWithStatus) => boolean;
const TYPE_ENABLED: Record<QuickConnectType, EnableCheck> = {
terminal: (h) => !!(h.enableSsh && h.enableTerminal),
files: (h) => !!(h.enableSsh && h.enableFileManager),
docker: (h) => !!(h.enableSsh && h.enableDocker),
tunnel: (h) => !!(h.enableSsh && h.enableTunnel),
"host-metrics": (h) => !!h.enableSsh,
rdp: (h) => !!h.enableRdp,
vnc: (h) => !!h.enableVnc,
telnet: (h) => !!h.enableTelnet,
};
import { useHostActions } from "@/sidebar/host-contributions";
import { quickConnectTargets } from "../quick-connect-targets";
function statusDotClass(host: SSHHostWithStatus): string {
if (host.status === "online") return "bg-green-500";
@@ -68,6 +36,7 @@ function QuickConnectWidget({
const { t } = useTranslation();
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [loading, setLoading] = useState(true);
const targets = quickConnectTargets(useHostActions());
const types =
config.connectionTypes.length > 0
@@ -109,9 +78,10 @@ function QuickConnectWidget({
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
{hosts.map((host) => {
const availableTypes = types.filter((type) =>
TYPE_ENABLED[type](host),
);
const availableTypes = types.flatMap((type) => {
const target = targets.find((item) => item.type === type);
return target && target.enabled(host) ? [target] : [];
});
if (availableTypes.length === 0) return null;
return (
<div
@@ -129,18 +99,18 @@ function QuickConnectWidget({
</span>
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap">
{availableTypes.map((type) => (
{availableTypes.map((target) => (
<button
key={type}
title={t(`homepage.connType_${type}`, type)}
key={target.type}
title={t(target.labelKey, target.type)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
openTab(host, type);
openTab(host, target.type);
}}
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
>
{TYPE_ICONS[type]}
<target.icon size={12} />
</button>
))}
</div>
@@ -1,13 +1,7 @@
import { useEffect, useState } from "react";
import {
Activity,
Terminal,
FolderOpen,
Container,
Wifi,
Monitor,
} from "lucide-react";
import { Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
import { activityTarget } from "@/lib/activity-types";
import { registerWidget } from "./WidgetRegistry";
import type {
RecentActivityConfig,
@@ -33,23 +27,8 @@ function relativeTime(ts: string): string {
}
function ActivityIcon({ type }: { type: string }) {
const cls = "shrink-0 text-accent-brand";
switch (type) {
case "terminal":
return <Terminal size={10} className={cls} />;
case "file_manager":
return <FolderOpen size={10} className={cls} />;
case "docker":
return <Container size={10} className={cls} />;
case "tunnel":
return <Wifi size={10} className={cls} />;
case "rdp":
case "vnc":
case "telnet":
return <Monitor size={10} className={cls} />;
default:
return <Activity size={10} className={cls} />;
}
const Icon = activityTarget(type)?.icon ?? Activity;
return <Icon className="size-2.5 shrink-0 text-accent-brand" />;
}
function RecentActivityWidget({
@@ -2,11 +2,13 @@ import type {
WidgetTypeDefinition,
WidgetTypeId,
} from "@/types/homepage-types";
import { createRegistry } from "@/lib/registry";
const registry = new Map<WidgetTypeId, WidgetTypeDefinition>();
/** Core widgets register on import; plugin widgets while their plugin runs. */
const registry = createRegistry<WidgetTypeDefinition>();
export function registerWidget<C>(def: WidgetTypeDefinition<C>): void {
registry.set(def.id, def as unknown as WidgetTypeDefinition);
export function registerWidget<C>(def: WidgetTypeDefinition<C>): () => void {
return registry.register(def as unknown as WidgetTypeDefinition);
}
export function getWidgetType(
@@ -16,5 +18,7 @@ export function getWidgetType(
}
export function getAllWidgetTypes(): WidgetTypeDefinition[] {
return Array.from(registry.values());
return registry.list();
}
export const useWidgetTypes = registry.useList;
@@ -1,7 +1,8 @@
import { useState } from "react";
import { Pencil, Trash2, GripVertical } from "lucide-react";
import type { CanvasWidget } from "@/types/homepage-types";
import { getWidgetType } from "./WidgetRegistry";
import { getWidgetType, useWidgetTypes } from "./WidgetRegistry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
interface WidgetShellProps {
widget: CanvasWidget;
@@ -31,6 +32,8 @@ export function WidgetShell({
children,
}: WidgetShellProps) {
const [hovered, setHovered] = useState(false);
// A plugin widget appears once its plugin starts.
useWidgetTypes();
const typeDef = getWidgetType(widget.typeId);
const showControls =
hovered && !isLocked && !isReadOnly && !isDragging && !isResizing;
@@ -87,9 +90,7 @@ export function WidgetShell({
onConfigUpdate={onConfigUpdate}
/>
) : (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
Unknown widget
</div>
<PluginViewPlaceholder kind="card" viewId={widget.typeId} compact />
))}
{/* Hover controls */}
@@ -1,261 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Search, Server as ServerIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/input";
import type { ProxmoxGuestSummary } from "@/types/proxmox";
type StatusFilter = "all" | "running" | "stopped";
type TypeFilter = "all" | "qemu" | "lxc";
function formatUptime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function UsageCell({
percent,
usedGiB,
totalGiB,
}: {
percent: number | null;
usedGiB?: number | null;
totalGiB?: number | null;
}) {
if (percent === null) {
return <span className="text-[11px] text-muted-foreground">-</span>;
}
const clamped = Math.min(100, Math.max(0, percent));
const barColor =
clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-[92px] flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold tabular-nums">
{clamped.toFixed(0)}%
</span>
{usedGiB != null && totalGiB != null && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return guests
.filter(
(g) =>
statusFilter === "all" ||
(statusFilter === "running") === (g.status === "running"),
)
.filter((g) => typeFilter === "all" || g.type === typeFilter)
.filter(
(g) =>
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
)
.sort((a, b) => {
if (a.status === b.status) return a.name.localeCompare(b.name);
return a.status === "running" ? -1 : 1;
});
}, [guests, query, statusFilter, typeFilter]);
const running = guests.filter((g) => g.status === "running").length;
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("proxmoxStats.guestsSummary")}
</span>
<span className="text-[11px] text-muted-foreground">
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("proxmoxStats.searchGuests")}
className="h-7 w-40 pl-6 text-xs"
/>
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
<button
key={f}
onClick={() => setTypeFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
typeFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "qemu"
? t("proxmoxStats.filterVm")
: t("proxmoxStats.filterLxc")}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
statusFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</button>
))}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{guests.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuests")}
</div>
) : filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuestsMatch")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.name")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.status")}
</th>
<th className="px-3 py-2 text-left font-semibold">ID</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.cpu")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.mem")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.disk")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.uptime")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((guest) => (
<tr
key={`${guest.type}-${guest.vmid}`}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{guest.type === "lxc" ? (
<Box className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate font-semibold">
{guest.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
{guest.type === "lxc" ? "LXC" : "VM"}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
guest.status === "running"
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span
className={cn(
"font-medium",
guest.status === "running"
? "text-foreground"
: "text-muted-foreground",
)}
>
{guest.status === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</span>
</div>
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{guest.vmid}
</td>
<td className="px-3 py-2">
<UsageCell percent={guest.cpuPercent} />
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.memPercent}
usedGiB={guest.memUsedGiB}
totalGiB={guest.memTotalGiB}
/>
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.diskPercent}
usedGiB={guest.diskUsedGiB}
totalGiB={guest.diskTotalGiB}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(guest.uptimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -1,147 +0,0 @@
import { useTranslation } from "react-i18next";
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ProxmoxNodeStats } from "@/types/proxmox";
function Tile({
icon,
label,
percent,
detail,
sparkline,
}: {
icon: React.ReactNode;
label: string;
percent: number | null;
detail?: string | null;
sparkline?: number[];
}) {
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
const barColor =
clamped === null
? "bg-muted-foreground/30"
: clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<span className="text-[10px] font-bold uppercase tracking-widest">
{label}
</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-bold tabular-nums">
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
</span>
{detail && (
<span className="truncate text-[11px] text-muted-foreground">
{detail}
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-all", barColor)}
style={{ width: `${clamped ?? 0}%` }}
/>
</div>
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
</div>
);
}
function MiniSparkline({ data }: { data: number[] }) {
const w = 100;
const h = 20;
const max = 100;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
export function NodeSummaryStrip({
node,
histories,
}: {
node: ProxmoxNodeStats | undefined;
histories: { cpu: number[]; memory: number[]; disk: number[] };
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Tile
icon={<Cpu className="size-3.5" />}
label={t("proxmoxStats.cpuUsage")}
percent={node?.cpu.percent ?? null}
detail={
node?.cpu.cores != null
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
: undefined
}
sparkline={histories.cpu}
/>
<Tile
icon={<MemoryStick className="size-3.5" />}
label={t("proxmoxStats.memoryUsage")}
percent={node?.memory.percent ?? null}
detail={
node?.memory.usedGiB != null && node?.memory.totalGiB != null
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.memory}
/>
<Tile
icon={<HardDrive className="size-3.5" />}
label={t("proxmoxStats.diskUsage")}
percent={node?.disk.percent ?? null}
detail={
node?.disk.usedGiB != null && node?.disk.totalGiB != null
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.disk}
/>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t("proxmoxStats.uptime")}
</span>
</div>
<span className="text-2xl font-bold">
{node?.uptime.formatted ?? "N/A"}
</span>
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
<Server className="size-3 shrink-0" />
<span className="truncate">
{node?.system.hostname ?? "-"}
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
</span>
</div>
</div>
</div>
);
}
@@ -1,52 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ProxmoxStatsTab } from "@/features/proxmox-stats/ProxmoxStatsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface ProxmoxStatsAppProps {
hostId?: string;
}
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<ProxmoxStatsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default ProxmoxStatsApp;
@@ -1,385 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Button } from "@/components/button.tsx";
import {
getProxmoxStats,
startProxmoxStatsPolling,
stopProxmoxStatsPolling,
sendProxmoxStatsHeartbeat,
getSSHHosts,
} from "@/main-axios.ts";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import type { ProxmoxStatsConfig } from "@/types";
import { useTranslation } from "react-i18next";
import { RefreshCw, Server } from "lucide-react";
import { NodeSummaryStrip } from "./NodeSummaryStrip";
import { GuestTable } from "./GuestTable";
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
const HISTORY_LEN = 30;
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.node.cpu.percent),
memory: bucket(data.node.memory.percent),
disk: bucket(data.node.disk.percent),
guests: data.guests.counts,
cluster: data.cluster,
});
}
const DEFAULT_POLL_INTERVAL = 60;
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
authType?: string;
port?: number;
[key: string]: unknown;
}
interface ProxmoxStatsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
interface ProxmoxStatsHistories {
cpu: number[];
memory: number[];
disk: number[];
}
function parseProxmoxStatsConfig(
raw?: string | ProxmoxStatsConfig | null,
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
if (!raw) return defaults;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...defaults, ...parsed };
} catch {
return defaults;
}
}
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ProxmoxStatsInner {...props} />
</ConnectionLogProvider>
);
}
function ProxmoxStatsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ProxmoxStatsProps): React.ReactElement {
const { t } = useTranslation();
const { addLog, clearLogs } = useConnectionLog();
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
null,
);
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
cpu: [],
memory: [],
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const statsConfig = React.useMemo(
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
[currentHostConfig?.proxmoxStatsConfig],
);
React.useEffect(() => {
const onVis = () => setIsPageVisible(!document.hidden);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const interval = setInterval(() => {
sendProxmoxStatsHeartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setSnapshot(null);
setHistories({ cpu: [], memory: [], disk: [] });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatest = async () => {
if (!hostConfig?.id) return;
try {
const hosts = await getSSHHosts();
const updated = hosts.find((h) => h.id === hostConfig.id);
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
} catch {
// keep previous config
}
};
fetchLatest();
const onChanged = () => fetchLatest();
window.addEventListener("ssh-hosts:changed", onChanged);
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
}, [hostConfig?.id]);
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.node?.cpu?.percent),
memory: add(prev.memory, data.node?.memory?.percent),
disk: add(prev.disk, data.node?.disk?.percent),
};
});
}, []);
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
const stopPollingRef = React.useRef<(() => void) | null>(null);
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
addLog({
type: "info",
stage: "stats_connecting",
message: t("proxmoxStats.connecting"),
});
const result = await startProxmoxStatsPolling(currentHostConfig.id);
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
addLog({
type: "info",
stage: "stats_polling",
message: t("proxmoxStats.connecting"),
});
const data = await getProxmoxStats(currentHostConfig.id);
if (!data) {
throw new Error(t("proxmoxStats.connectionFailed"));
}
setSnapshot(data);
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
const intervalMs =
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
let signature = statsChangeKey(data);
stopPollingRef.current?.();
stopPollingRef.current = runAdaptivePolling(
async () => {
const next = await getProxmoxStats(currentHostConfig.id);
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
const nextSignature = statsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setSnapshot(next);
pushHistory(next);
return changed;
},
{
minIntervalMs: intervalMs,
maxIntervalMs: Math.min(120_000, intervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [currentHostConfig?.id, statsConfig.pollInterval, addLog, t, pushHistory]);
const retry = useConnectionRetry({
connect: async () => {
try {
await fetchSnapshot();
retry.markConnected();
} catch (error: unknown) {
addLog({
type: "error",
stage: "error",
message:
error instanceof Error
? error.message
: t("proxmoxStats.connectionFailed"),
});
retry.markFailed();
}
},
enabled: isPageVisible && !notEnabled && !!currentHostConfig?.id,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
// Connects once per host and stays connected while this tab exists, even
// when the user switches to another tab and back. Only the browser tab
// going into the background (isPageVisible) pauses/resumes it -- switching
// between Termix tabs must not tear down and reconnect the session.
React.useEffect(() => {
if (notEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const debounce = setTimeout(() => {
if (isPageVisible && !cancelled) {
clearLogs();
retryRef.current.reset();
retryRef.current.retryNow();
} else if (!isPageVisible) {
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
stopProxmoxStatsPolling(currentHostConfig.id, undefined).catch(
() => {},
);
}
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounce);
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
stopProxmoxStatsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, notEnabled, isPageVisible]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
};
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (retry.status !== "connected") {
retry.retryNow();
return;
}
try {
setIsRefreshing(true);
const data = await getProxmoxStats(currentHostConfig.id);
if (data) {
setSnapshot(data);
pushHistory(data);
}
} finally {
setIsRefreshing(false);
}
};
const showContent = !notEnabled && retry.status === "connected" && snapshot;
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{retry.status === "connected" && !notEnabled && (
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
</div>
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
</div>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("proxmoxStats.refresh")}
</Button>
</div>
)}
{showContent && (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
<div className="shrink-0">
<NodeSummaryStrip node={snapshot.node} histories={histories} />
</div>
<GuestTable guests={snapshot.guests.guests} />
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
<NodeNetworkCard snapshot={snapshot} />
<StoragePoolsCard snapshot={snapshot} />
{snapshot.cluster.clustered && (
<ClusterHealthCard snapshot={snapshot} />
)}
</div>
</div>
)}
<ConnectionScreen
status={notEnabled ? "connected" : retry.status}
message={t("proxmoxStats.connecting")}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={retry.retryNow}
emptyState={
notEnabled ? (
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("proxmoxStats.noHostSelected")}
</p>
</div>
) : undefined
}
/>
</div>
</div>
);
}
@@ -1,67 +0,0 @@
import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { cn } from "@/lib/utils";
import { MetricCard } from "@/components/metric-card";
export function ClusterHealthCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const cluster = snapshot?.cluster;
return (
<MetricCard
title={t("proxmoxStats.clusterHealth")}
icon={<Network className="size-3.5" />}
scroll
>
{!cluster || !cluster.clustered ? (
<span className="text-xs text-muted-foreground">
{t("proxmoxStats.notClustered")}
</span>
) : (
<div className="flex flex-col gap-2 text-xs">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
cluster.quorate ? "bg-green-500" : "bg-red-500",
)}
/>
<span className="font-medium">
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
</span>
<span className="text-muted-foreground">
{cluster.quorate
? t("proxmoxStats.quorate")
: t("proxmoxStats.notQuorate")}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{cluster.nodes.map((node) => (
<div key={node.name} className="flex items-center gap-2 py-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
node.online ? "bg-green-500" : "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{node.online
? t("proxmoxStats.nodeOnline")
: t("proxmoxStats.nodeOffline")}
</span>
</div>
))}
</div>
</div>
)}
</MetricCard>
);
}
@@ -1,37 +0,0 @@
import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
export function NodeNetworkCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const interfaces = snapshot?.network.interfaces ?? [];
return (
<MetricCard
title={t("proxmoxStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll
>
{interfaces.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{interfaces.map((iface) => (
<StatRow
key={iface.name}
label={iface.name}
value={iface.ip ?? iface.state ?? "-"}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -1,60 +0,0 @@
import { Database } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { MetricCard } from "@/components/metric-card";
export function StoragePoolsCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const pools = snapshot?.storage.pools ?? [];
return (
<MetricCard
title={t("proxmoxStats.storagePools")}
icon={<Database className="size-3.5" />}
scroll
>
{pools.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{pools.map((pool) => (
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{pool.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
{pool.type}
</span>
{!pool.active && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("proxmoxStats.inactive")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-accent-brand"
style={{
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{pool.usedGiB !== null && pool.totalGiB !== null
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,59 @@
import { useMemo } from "react";
import { useActionSlot } from "@/hooks/use-action-slot";
import { invokeAction, isActionRegistered } from "@/shell/action-registry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
/**
* A remote desktop session drawn by whichever plugin provides one.
*
* Collab rooms and shared-session links show RDP, VNC and Telnet streams
* without knowing how they are drawn. The provider contributes a component
* to this slot and handles the token action below.
*/
export const REMOTE_DISPLAY_SLOT = "session.remoteDisplay";
export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
export interface RemoteDisplayProps {
token: string;
protocol: string;
isVisible: boolean;
onConnect?: () => void;
onError?: (error: string) => void;
}
export function RemoteDisplay(props: RemoteDisplayProps) {
const contributions = useActionSlot(REMOTE_DISPLAY_SLOT);
const provider = useMemo(
() =>
contributions.find(
(contribution) =>
contribution.kind === "component" && contribution.component,
),
[contributions],
);
if (!provider?.component) {
return <PluginViewPlaceholder kind="tab" viewId={props.protocol} />;
}
const Display = provider.component;
return <Display {...(props as unknown as Record<string, unknown>)} />;
}
export interface RemoteSessionToken {
token: string;
connectionId: string;
}
/** Mints a session token for presenting a host, through the provider. */
export async function createRemoteSessionToken(
hostId: number,
origin: unknown,
protocol: string,
): Promise<RemoteSessionToken | null> {
if (!isActionRegistered(REMOTE_SESSION_TOKEN_ACTION)) return null;
return (await invokeAction(
REMOTE_SESSION_TOKEN_ACTION,
hostId,
origin,
protocol,
)) as RemoteSessionToken | null;
}
@@ -10,7 +10,7 @@ import {
} from "@/api/session-sharing-api";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay";
const PING_INTERVAL_MS = 30000;
@@ -286,12 +286,9 @@ function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
/>
</div>
)}
<GuacamoleDisplay
connectionConfig={{
token: share.connectParams.token,
protocol: share.protocol as "rdp" | "vnc" | "telnet",
type: share.protocol as "rdp" | "vnc" | "telnet",
}}
<RemoteDisplay
token={share.connectParams.token}
protocol={share.protocol}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
+145 -133
View File
@@ -12,6 +12,7 @@ import {
useImperativeHandle,
forwardRef,
useCallback,
useMemo,
} from "react";
import { createPortal } from "react-dom";
import { useXTerm } from "react-xtermjs";
@@ -43,7 +44,6 @@ import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx";
import { TailscaleCheckDialog } from "../../../../plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx";
import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx";
import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx";
import {
@@ -74,8 +74,15 @@ import {
CommandAutosuggestion,
} from "./command-history/CommandAutocomplete.tsx";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useAiAvailability } from "@/hooks/use-ai-availability.ts";
import { TerminalAiPanel } from "./TerminalAiPanel.tsx";
import { useActionSlot } from "@/hooks/use-action-slot";
import { ComponentSlot } from "@/shell/ActionSlot";
import {
TERMINAL_DOCK_SLOT,
TERMINAL_OVERLAY_SLOT,
type TerminalOverlayProps,
type TerminalSessionMessage,
type TerminalSlotApi,
} from "./terminal-slots";
import {
ConnectionLogProvider,
useConnectionLog,
@@ -83,7 +90,7 @@ import {
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Bot, Save } from "lucide-react";
import { Save } from "lucide-react";
import { authApi } from "@/main-axios.ts";
import { resolveTermixThemeColors } from "./terminal-theme.ts";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
@@ -326,14 +333,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} | null>(null);
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [tailscaleCheckDialog, setTailscaleCheckDialog] = useState<{
isOpen: boolean;
authUrl: string;
message?: string;
stage: "prompt" | "waiting";
} | null>(null);
const tailscaleCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const tailscaleCheckPendingRef = useRef(false);
// Overlays contributed by plugins (a Tailscale check, say) hear the
// session's messages and may hold the connect timeout while they wait.
const sessionListenersRef = useRef(
new Set<(message: TerminalSessionMessage) => void>(),
);
const connectTimeoutHoldsRef = useRef(0);
const emitSessionMessage = (message: TerminalSessionMessage) => {
for (const listener of sessionListenersRef.current) {
try {
listener(message);
} catch (error) {
console.error("[terminal] overlay listener threw", error);
}
}
};
const opksshFailedRef = useRef(false);
const currentHostIdRef = useRef<number | null>(null);
@@ -460,11 +474,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
});
const [autosuggestionStyle, setAutosuggestionStyle] =
useState<React.CSSProperties>({});
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
const [aiAssistantContext, setAiAssistantContext] = useState("");
const { userEnabled: aiAssistantEnabledForUser } = useAiAvailability();
const isAiAssistantAvailable =
aiAssistantEnabledForUser && host?.enableAiAssistant === true;
const [dock, setDock] = useState<{
id: string;
props: Record<string, unknown>;
} | null>(null);
const dockContributions = useActionSlot(TERMINAL_DOCK_SLOT, { host });
const hasDock = dockContributions.length > 0;
const autocompleteHistory = useRef<string[]>([]);
const currentAutocompleteCommand = useRef<string>("");
const currentAutosuggestionCommand = useRef<string>("");
@@ -728,26 +743,16 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return true;
}, [clearAutosuggestion]);
const toggleAiAssistant = useCallback(() => {
setAiAssistantOpen((open) => !open);
}, []);
const firstDockId = dockContributions[0]?.actionId;
// Ctrl+Shift+A and the floating button toggle the first docked panel.
const toggleDock = useCallback(() => {
setDock((open) =>
open || !firstDockId ? null : { id: firstDockId, props: {} },
);
}, [firstDockId]);
// A plugin-contributed action (the toolbar's AI button) asks for the panel
// this way, so the contribution needs no reference into the terminal.
useEffect(() => {
if (!isFocusedPane) return;
const open = (event: Event) => {
const detail = (event as CustomEvent<{ context?: string }>).detail;
setAiAssistantContext(detail?.context ?? "");
setAiAssistantOpen(true);
};
window.addEventListener("termix:ai:openWithContext", open);
return () =>
window.removeEventListener("termix:ai:openWithContext", open);
}, [isFocusedPane]);
const closeAiAssistant = useCallback(() => {
setAiAssistantOpen(false);
const closeDock = useCallback(() => {
setDock(null);
setTimeout(() => terminal?.focus(), 50);
}, [terminal]);
@@ -771,6 +776,50 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
[clearAutosuggestion, terminal, trackInput],
);
const slotApi = useMemo<TerminalSlotApi>(
() => ({
host,
getBufferText: () => getTerminalBufferText(terminal),
openDock: (dockId, props) =>
setDock({ id: dockId, props: props ?? {} }),
runCommand: handleRunCommandInTerminal,
}),
[host, terminal, handleRunCommandInTerminal],
);
const overlayProps = useMemo<TerminalOverlayProps>(
() => ({
host,
backgroundColor,
subscribe: (listener) => {
sessionListenersRef.current.add(listener);
return () => {
sessionListenersRef.current.delete(listener);
};
},
holdConnectTimeout: (held) => {
if (held) {
connectTimeoutHoldsRef.current += 1;
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else {
connectTimeoutHoldsRef.current = Math.max(
0,
connectTimeoutHoldsRef.current - 1,
);
}
},
fail: (message) => {
updateConnectionError(message);
webSocketRef.current?.close();
},
disconnect: () => webSocketRef.current?.close(),
}),
[host, backgroundColor, updateConnectionError],
);
const activityLoggingRef = useRef(false);
const passwordPromptShownRef = useRef(false);
const passwordPromptBufferRef = useRef("");
@@ -1618,7 +1667,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
!isConnected &&
!totpRequired &&
!isPasswordPrompt &&
!tailscaleCheckPendingRef.current &&
connectTimeoutHoldsRef.current === 0 &&
!connectionErrorRef.current
) {
if (terminal) {
@@ -1778,6 +1827,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type !== "data" && msg.type !== "pong") {
emitSessionMessage(msg);
}
if (msg.type === "pong") {
pongReceivedRef.current = true;
return;
@@ -2232,42 +2284,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
stage: "error",
error: msg.instructions || msg.error,
});
} else if (msg.type === "tailscale_check_required") {
if (connectionErrorRef.current) return;
tailscaleCheckPendingRef.current = true;
// Tailscale holds the connection open while the user authenticates,
// so the normal connect timeout must not fire during the wait.
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
setTailscaleCheckDialog({
isOpen: true,
authUrl: msg.url || "",
message: msg.message,
stage: "prompt",
});
if (tailscaleCheckTimeoutRef.current) {
clearTimeout(tailscaleCheckTimeoutRef.current);
}
tailscaleCheckTimeoutRef.current = setTimeout(() => {
tailscaleCheckPendingRef.current = false;
setTailscaleCheckDialog(null);
updateConnectionError(t("terminal.tailscaleCheckTimeout"));
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 1800000);
} else if (msg.type === "tailscale_check_completed") {
tailscaleCheckPendingRef.current = false;
if (tailscaleCheckTimeoutRef.current) {
clearTimeout(tailscaleCheckTimeoutRef.current);
tailscaleCheckTimeoutRef.current = null;
}
setTailscaleCheckDialog(null);
} else if (msg.type === "keyboard_interactive_available") {
setKeyboardInteractiveDetected(true);
setIsConnecting(false);
@@ -2467,12 +2483,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
totpTimeoutRef.current = null;
}
tailscaleCheckPendingRef.current = false;
if (tailscaleCheckTimeoutRef.current) {
clearTimeout(tailscaleCheckTimeoutRef.current);
tailscaleCheckTimeoutRef.current = null;
}
setTailscaleCheckDialog(null);
connectTimeoutHoldsRef.current = 0;
emitSessionMessage({ type: "session_closed" });
if (wasSessionExpiredRef.current) {
wasSessionExpiredRef.current = false;
@@ -3252,7 +3264,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (
isAiAssistantAvailable &&
hasDock &&
e.ctrlKey &&
e.shiftKey &&
!e.altKey &&
@@ -3261,7 +3273,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
) {
e.preventDefault();
e.stopPropagation();
toggleAiAssistant();
toggleDock();
return false;
}
const macLineNav = getMacLineNavigationSequence(e);
@@ -3584,7 +3596,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
};
terminal.attachCustomKeyEventHandler(handleCustomKey);
}, [isAiAssistantAvailable, toggleAiAssistant, terminal]);
}, [hasDock, toggleDock, terminal]);
useEffect(() => {
if (!terminal || !hostConfig || !isVisible) return;
@@ -3807,19 +3819,33 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
/>
{isConnected &&
isAiAssistantAvailable &&
host?.enableTerminalToolbar === false && (
<Button
type="button"
size="icon"
variant="secondary"
onClick={toggleAiAssistant}
title={t("ai.assistant") + " (Ctrl+Shift+A)"}
className="absolute top-2 right-2 z-[110] size-8 bg-black/60 text-white/75 hover:bg-black/80 hover:text-white"
>
<Bot className="size-4" />
</Button>
)}
host?.enableTerminalToolbar === false &&
dockContributions.map((contribution, index) => {
const Icon = contribution.icon;
return (
<Button
key={contribution.actionId}
type="button"
size="icon"
variant="secondary"
onClick={() =>
setDock((open) =>
open?.id === contribution.actionId
? null
: { id: contribution.actionId, props: {} },
)
}
title={
t(contribution.titleKey) +
(index === 0 ? " (Ctrl+Shift+A)" : "")
}
className="absolute top-2 z-[110] size-8 bg-black/60 text-white/75 hover:bg-black/80 hover:text-white"
style={{ right: 8 + index * 40 }}
>
{Icon && <Icon className="size-4" />}
</Button>
);
})}
{host && host.enableTerminalToolbar !== false && (
<TerminalToolbar
@@ -3845,21 +3871,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}}
isFocused={isFocusedPane}
actionsEnabled={isAiAssistantAvailable}
getBufferText={() => getTerminalBufferText(terminal)}
slotApi={slotApi}
/>
)}
{aiAssistantOpen && isAiAssistantAvailable && hostConfig.id && (
<TerminalAiPanel
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
hostId={hostConfig.id}
activeTab={`terminal:${hostConfig.name || hostConfig.ip}`}
initialContext={aiAssistantContext}
onClose={closeAiAssistant}
onRunInTerminal={handleRunCommandInTerminal}
/>
)}
{dock &&
(() => {
const contribution = dockContributions.find(
(item) => item.actionId === dock.id,
);
const Panel = contribution?.component;
if (!Panel) return null;
return (
<Panel
host={host}
hostId={hostConfig.id}
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
dockProps={dock.props}
onClose={closeDock}
onRunInTerminal={handleRunCommandInTerminal}
/>
);
})()}
{isQuickConnect &&
isConnected &&
@@ -4030,32 +4063,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
/>
)}
{tailscaleCheckDialog?.isOpen && (
<TailscaleCheckDialog
isOpen={tailscaleCheckDialog.isOpen}
authUrl={tailscaleCheckDialog.authUrl}
message={tailscaleCheckDialog.message}
stage={tailscaleCheckDialog.stage}
onCancel={() => {
tailscaleCheckPendingRef.current = false;
if (tailscaleCheckTimeoutRef.current) {
clearTimeout(tailscaleCheckTimeoutRef.current);
tailscaleCheckTimeoutRef.current = null;
}
setTailscaleCheckDialog(null);
if (webSocketRef.current) {
webSocketRef.current.close();
}
}}
onOpenUrl={() => {
window.open(tailscaleCheckDialog.authUrl, "_blank");
setTailscaleCheckDialog((prev) =>
prev ? { ...prev, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
)}
<ComponentSlot
slotId={TERMINAL_OVERLAY_SLOT}
when={{ host }}
props={overlayProps as unknown as Record<string, unknown>}
/>
{vaultDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60">
@@ -1,419 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
import { Button } from "@/components/button";
import { getAiProviders, type AiProposal, type AiProvider } from "@/api/ai-api";
import { AiMessage } from "@/features/ai/AiMessage";
import { AiToolCall } from "@/features/ai/AiToolCall";
import { useAiStream } from "@/features/ai/use-ai-stream";
import { TerminalProposalCard } from "./TerminalProposalCard";
import {
clampToolbarPosition,
persistAiPanelPosition,
readStoredAiPanelPosition,
type ToolbarPosition,
} from "./toolbar-geometry";
interface HistoryEntry {
role: "user" | "assistant";
content: string;
}
type TimelineItem =
| {
kind: "message";
key: string;
role: "user" | "assistant";
content: string;
}
| {
kind: "tool";
key: string;
tool: import("@/features/ai/use-ai-stream").ToolActivity;
}
| { kind: "proposal"; key: string; proposal: AiProposal };
interface TerminalAiPanelProps {
hostLabel: string;
hostId: number;
activeTab?: string | null;
/** Terminal output to open with, from a contributed action. */
initialContext?: string;
onClose: () => void;
onRunInTerminal: (command: string) => void;
}
/**
* A slimmed-down version of AiPanel docked to a terminal tab. Same backend
* (/ai/chat/stream, propose_run_command, ProposalCard) as the main assistant,
* so it respects the same admin/user gates and never runs a command without
* an explicit approve.
*/
export function TerminalAiPanel({
hostLabel,
hostId,
activeTab,
initialContext,
onClose,
onRunInTerminal,
}: TerminalAiPanelProps) {
const { t } = useTranslation();
const { state, send, stop } = useAiStream();
const [providers, setProviders] = useState<AiProvider[]>([]);
const [providerId, setProviderId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState("");
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [resolvedProposals, setResolvedProposals] = useState<
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
>({});
const conversationIdRef = useRef<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Prefilled rather than sent: the user sees exactly what the terminal is
// about to hand the model, and can trim it or add a question first.
useEffect(() => {
if (!initialContext) return;
setInput((current) =>
current
? current
: t("ai.terminalContextPrefill", { output: initialContext }),
);
}, [initialContext, t]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<ToolbarPosition>(
readStoredAiPanelPosition,
);
const positionRef = useRef(position);
positionRef.current = position;
const panelRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
basePosition: ToolbarPosition;
} | null>(null);
useLayoutEffect(() => {
const panel = panelRef.current;
const host = panel?.offsetParent as HTMLElement | null;
if (!panel || !host) return;
const next = clampToolbarPosition(
positionRef.current,
panel.getBoundingClientRect(),
host.getBoundingClientRect(),
positionRef.current,
);
if (next.x !== positionRef.current.x || next.y !== positionRef.current.y) {
positionRef.current = next;
setPosition(next);
}
// Only re-clamp when the panel first mounts; drag handles the rest.
}, []);
const handleDragPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || event.pointerType === "touch") return;
if ((event.target as HTMLElement).closest("button")) return;
event.currentTarget.setPointerCapture?.(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
basePosition: positionRef.current,
};
};
const handleDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const panel = panelRef.current;
const host = panel?.offsetParent as HTMLElement | null;
if (!panel || !host) return;
const deltaX = event.clientX - drag.startX;
const deltaY = event.clientY - drag.startY;
const next = clampToolbarPosition(
{ x: drag.basePosition.x + deltaX, y: drag.basePosition.y + deltaY },
panel.getBoundingClientRect(),
host.getBoundingClientRect(),
positionRef.current,
);
positionRef.current = next;
setPosition(next);
};
const handleDragPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
persistAiPanelPosition(positionRef.current);
dragRef.current = null;
if (event.type !== "lostpointercapture") {
try {
event.currentTarget.releasePointerCapture?.(event.pointerId);
} catch {
// The browser may have already released capture for this pointer.
}
}
};
useEffect(() => {
let cancelled = false;
getAiProviders()
.then((list) => {
if (cancelled) return;
setProviders(list);
setProviderId((current) => current ?? list[0]?.id ?? null);
})
.catch(() => {
if (!cancelled) setProviders([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
window.requestAnimationFrame(() => textareaRef.current?.focus());
}, []);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [state.assistantText, state.tools.length, history.length]);
const handleResolved = useCallback(
(id: number, status: "applied" | "rejected", resultSummary?: string) => {
setResolvedProposals((current) => ({
...current,
[id]: { status, resultSummary },
}));
},
[],
);
const handleSend = useCallback(() => {
const message = input.trim();
if (!message || !providerId || state.streaming) return;
setHistory((current) => [...current, { role: "user", content: message }]);
setInput("");
void send({
message,
providerId,
conversationId: conversationIdRef.current,
activeTab: activeTab ?? `terminal:${hostLabel}`,
onComplete: (conversationId, reply) => {
conversationIdRef.current = conversationId;
if (reply) {
setHistory((current) => [
...current,
{ role: "assistant", content: reply },
]);
}
},
});
}, [activeTab, hostLabel, input, providerId, send, state.streaming]);
const proposals: AiProposal[] = state.proposals.map((proposal) => {
const resolved = resolvedProposals[proposal.id];
if (!resolved) return proposal;
return {
...proposal,
status: resolved.status,
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
};
});
// The reply exists in two places once a turn finishes: the streamed text in
// `state`, and the copy `onComplete` appended to `history`. Show it once,
// and keep tool calls spliced in ahead of the reply they produced instead
// of in a separate trailing block, so the timeline reads in the order
// things actually happened rather than text-then-tools-then-proposals.
const last = history[history.length - 1];
const historyEndsWithThisReply =
state.assistantText.length > 0 &&
last?.role === "assistant" &&
last.content === state.assistantText;
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
const timeline: TimelineItem[] = [];
history.forEach((entry, index) => {
if (index === trailingReply) return;
timeline.push({
kind: "message",
key: `history-${index}`,
role: entry.role,
content: entry.content,
});
});
for (const tool of state.tools) {
timeline.push({ kind: "tool", key: tool.id, tool });
}
if (trailingReply >= 0) {
timeline.push({
kind: "message",
key: `history-${trailingReply}`,
role: "assistant",
content: history[trailingReply].content,
});
} else if (streamingReply) {
timeline.push({
kind: "message",
key: "streaming",
role: "assistant",
content: streamingReply,
});
}
for (const proposal of proposals) {
timeline.push({
kind: "proposal",
key: `proposal-${proposal.id}`,
proposal,
});
}
return (
<div
ref={panelRef}
className="absolute right-2 top-12 z-[120] flex max-h-[calc(100%-4rem)] w-[min(420px,calc(100vw-1rem))] flex-col border border-border bg-background shadow-xl"
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
onClick={(event) => event.stopPropagation()}
>
<div
className="flex cursor-grab items-center justify-between gap-2 border-b border-border px-3 py-2 active:cursor-grabbing"
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerEnd}
onPointerCancel={handleDragPointerEnd}
onLostPointerCapture={handleDragPointerEnd}
>
<div className="flex min-w-0 items-center gap-2">
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
<Bot className="size-4 shrink-0 text-accent-brand" />
<div className="min-w-0">
<div className="truncate text-xs font-bold text-foreground">
{t("ai.assistant")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{hostLabel}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={stop}
disabled={!state.streaming}
title={t("ai.stop")}
>
<Square className="size-3.5" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={onClose}
title={t("common.close")}
>
<X className="size-3.5" />
</Button>
</div>
</div>
<div
ref={scrollRef}
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
>
{timeline.length === 0 && !loading && (
<div className="text-xs text-muted-foreground">
{t("ai.terminalPanelEmpty")}
</div>
)}
{!loading && providers.length === 0 && (
<div className="border border-border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground">
{t("ai.noProvidersConfigured")}
</div>
)}
{state.error && (
<div className="border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{state.error}
</div>
)}
{timeline.map((item) => {
if (item.kind === "message") {
return (
<AiMessage
key={item.key}
role={item.role}
content={item.content}
/>
);
}
if (item.kind === "tool") {
return (
<AiToolCall
key={item.key}
tool={item.tool}
streaming={state.streaming}
/>
);
}
return (
<TerminalProposalCard
key={item.key}
proposal={item.proposal}
hostId={hostId}
onRunInTerminal={onRunInTerminal}
onResolved={handleResolved}
/>
);
})}
</div>
<div className="flex flex-col gap-2 border-t border-border p-3">
<textarea
ref={textareaRef}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}}
placeholder={t("ai.terminalPanelPlaceholder")}
rows={3}
className="min-h-20 resize-none border border-input bg-background px-2 py-1.5 text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={state.streaming || providers.length === 0}
/>
<Button
type="button"
size="sm"
className="h-8 gap-1.5"
disabled={state.streaming || !input.trim() || !providerId}
onClick={handleSend}
>
<Send className="size-3.5" />
{t("ai.send")}
</Button>
</div>
</div>
);
}
@@ -1,142 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import {
markAiProposalRunInTerminal,
rejectAiProposal,
type AiProposal,
} from "@/api/ai-api";
import { ProposalCard } from "@/features/ai/ProposalCard";
interface TerminalProposalCardProps {
proposal: AiProposal;
hostId: number;
onRunInTerminal: (command: string) => void;
onResolved: (
id: number,
status: "applied" | "rejected",
resultSummary?: string,
) => void;
}
/**
* A run_command proposal in the terminal-docked assistant runs in the user's
* already-open session instead of a hidden pooled connection, so approving it
* here is a different action from the main AiPanel's apply button. Every
* other proposal kind still goes through the normal apply flow.
*/
export function TerminalProposalCard({
proposal,
hostId,
onRunInTerminal,
onResolved,
}: TerminalProposalCardProps) {
const { t } = useTranslation();
const [busy, setBusy] = useState<"run" | "reject" | null>(null);
if (
proposal.kind !== "propose_run_command" ||
proposal.status !== "pending"
) {
return <ProposalCard proposal={proposal} onResolved={onResolved} />;
}
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(proposal.payload);
} catch {
payload = {};
}
const command = typeof payload.command === "string" ? payload.command : "";
const explanation =
typeof payload.explanation === "string" ? payload.explanation : "";
async function handleRun() {
if (!command) return;
setBusy("run");
try {
onRunInTerminal(command);
const result = await markAiProposalRunInTerminal(
proposal.id,
hostId,
`$ ${command}`,
);
onResolved(proposal.id, "applied", result.summary);
} catch (error) {
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
} finally {
setBusy(null);
}
}
async function handleReject() {
setBusy("reject");
try {
await rejectAiProposal(proposal.id);
onResolved(proposal.id, "rejected");
} catch (error) {
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
} finally {
setBusy(null);
}
}
return (
<div className="rounded-none border border-border bg-muted p-2.5">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{t("ai.runCommandTitle")}
</span>
</div>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
{command}
</pre>
{explanation && (
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
{explanation}
</p>
)}
<p className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
<TriangleAlert size={11} className="shrink-0" />
{t("ai.runsInOpenTerminal")}
</p>
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
className="h-7 flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
variant="outline"
disabled={busy !== null || !command}
onClick={handleRun}
>
{busy === "run" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
{t("ai.runInTerminal")}
</Button>
<Button
size="sm"
className="h-7 flex-1 text-xs"
variant="outline"
disabled={busy !== null}
onClick={handleReject}
>
{busy === "reject" ? (
<Loader2 size={13} className="animate-spin" />
) : (
<X size={13} />
)}
{t("ai.reject")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { lazy, Suspense } from "react";
import { TerminalSquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTabsSafe } from "@/shell/TabContext";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import type { TabRenderProps } from "@/shell/tab-registry";
const CommandHistoryProvider = lazy(() =>
import("./command-history/CommandHistoryContext").then((m) => ({
default: m.CommandHistoryProvider,
})),
);
export const loadTerminal = () =>
import("./Terminal").then((m) => ({ default: m.Terminal }));
const TerminalFeature = lazy(loadTerminal);
const MobileTerminalKeyboard = lazy(() =>
import("./MobileTerminalKeyboard").then((m) => ({
default: m.MobileTerminalKeyboard,
})),
);
function TabChunkFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
/**
* The SSH terminal as a tab. Core code, because the terminal's session
* manager, split view and file-manager hooks are core until the terminal's
* own Phase B step; the ssh-terminal plugin registers it as the "terminal"
* tab type, so it comes and goes with that plugin.
*/
export function TerminalTabContent({
tab,
host,
label,
isVisible,
isFocusedPane,
shell,
}: TabRenderProps) {
const { t } = useTranslation();
const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile();
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<TerminalSquare className="size-5 text-muted-foreground/30" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("terminal.noHostSelected")}
</span>
</div>
);
}
return (
<Suspense fallback={<TabChunkFallback />}>
<CommandHistoryProvider>
<div className="flex flex-col h-full w-full">
<div className="flex-1 min-h-0">
<TerminalFeature
ref={tab.terminalRef as React.Ref<TerminalHandle>}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null,
joinSharedSessionId: tab.joinSharedSessionId ?? null,
joinShareId: tab.joinShareId ?? null,
} as TerminalHostConfig
}
isVisible={isVisible}
initialPath={tab.initialFilePath}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => shell.closeTab(tab.id)}
onTitleChange={
host.terminalConfig?.useSSHTitle
? (title) => shell.renameTab(tab.id, title)
: undefined
}
previewTheme={previewTerminalTheme}
onOpenFileInEditor={(filePath) =>
shell.openFileInEditor(host, filePath)
}
onOpenFileManager={(path) => shell.openFileManager(host, path)}
isQuickConnect={host.id.startsWith("quick-connect-")}
onSaveQuickConnect={
shell.saveQuickConnect
? () => shell.saveQuickConnect!(tab, host)
: undefined
}
host={host}
onOpenTab={(type) => shell.openTab(host, type)}
isFocusedPane={isFocusedPane}
/>
</div>
{isMobile && (
<MobileTerminalKeyboard
terminalRef={
tab.terminalRef as React.RefObject<TerminalHandle | null>
}
/>
)}
</div>
</CommandHistoryProvider>
</Suspense>
);
}
+17 -11
View File
@@ -2,6 +2,7 @@ import React, {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -25,6 +26,7 @@ import {
} from "@/components/select";
import { cn } from "@/lib/utils";
import { TERMINAL_TOOLBAR_SLOT, type TerminalSlotApi } from "./terminal-slots";
import { ActionSlot } from "@/shell/ActionSlot";
import { useActionSlot } from "@/hooks/use-action-slot";
import { useIsMobile } from "@/hooks/use-mobile";
@@ -52,7 +54,7 @@ type SelectedToolbarDensity = ToolbarDensity;
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
const TOOLBAR_SLOT_ID = "terminal.toolbar";
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
{ value: "icon" },
@@ -124,14 +126,10 @@ interface TerminalToolbarProps {
onOpenTab?: (type: TabType) => void;
onOpenFiles?: () => void;
isFocused: boolean;
/**
* Whether this host offers the toolbar's contributed actions at all. The
* slot itself is generic; the host-level AI flag is not something a
* contributing plugin can know about.
*/
/** Hides every contributed action, e.g. while the toolbar is measuring. */
actionsEnabled?: boolean;
/** Read at click time by contributions that want the visible scrollback. */
getBufferText?: () => string;
/** Handed to contributed actions when they are invoked. */
slotApi?: TerminalSlotApi;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
@@ -147,12 +145,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onOpenFiles,
isFocused,
actionsEnabled = true,
getBufferText,
slotApi,
}) => {
const { t } = useTranslation();
// Drives both the measurement copy and the separator, so the toolbar sizes
// itself correctly whether or not anything contributed.
const slotContributions = useActionSlot(TOOLBAR_SLOT_ID);
const allSlotContributions = useActionSlot(TOOLBAR_SLOT_ID, { host });
const slotContributions = useMemo(
() =>
allSlotContributions.filter(
(contribution) => contribution.kind !== "component",
),
[allSlotContributions],
);
const [density, setDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [responsiveDensity, setResponsiveDensity] =
@@ -855,7 +860,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
className={CONTROL}
enabled={actionsEnabled}
hideLabels={effectiveDensity === "icon"}
context={() => [getBufferText?.() ?? ""]}
when={{ host }}
context={() => [slotApi]}
/>
</>
)}
@@ -0,0 +1,60 @@
import type { Host } from "@/types/ui-types";
/**
* The slots the terminal offers plugins, and what it hands them.
*
* - "terminal.toolbar" (buttons): actions are invoked with a TerminalSlotApi.
* - "terminal.dock" (components): a side panel an action opens with
* `openDock(id)`, rendered with TerminalDockProps.
* - "terminal.overlay" (components): always mounted over the terminal and
* given the session's server messages, for connection flows a plugin owns
* (a Tailscale check, say).
*
* Every contribution's `when` sees `{ host }`.
*/
export const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
export const TERMINAL_DOCK_SLOT = "terminal.dock";
export const TERMINAL_OVERLAY_SLOT = "terminal.overlay";
export interface TerminalSlotApi {
host: Host | undefined;
/** The visible scrollback, read at call time. */
getBufferText: () => string;
/** Opens a "terminal.dock" contribution by its actionId. */
openDock: (dockId: string, props?: Record<string, unknown>) => void;
/** Types a command into the session and runs it. */
runCommand: (command: string) => void;
}
export interface TerminalDockProps {
host: Host | undefined;
hostId: number | undefined;
hostLabel: string;
/** Whatever the opening action passed to openDock. */
dockProps: Record<string, unknown>;
onClose: () => void;
onRunInTerminal: (command: string) => void;
}
export interface TerminalSessionMessage {
type: string;
[key: string]: unknown;
}
export interface TerminalOverlayProps {
host: Host | undefined;
backgroundColor?: string;
/** Server messages for this session, plus a local "session_closed". */
subscribe: (
listener: (message: TerminalSessionMessage) => void,
) => () => void;
/**
* Holds the connect timeout while the overlay waits on the user, e.g. an
* approval in another window. Release it when done.
*/
holdConnectTimeout: (held: boolean) => void;
/** Ends the attempt with an error shown in the terminal. */
fail: (message: string) => void;
/** Closes the session's connection. */
disconnect: () => void;
}
+9 -29
View File
@@ -70,35 +70,15 @@ export function PanePreview({
setAttachNonce((n) => n + 1);
}
// For Tailscale-auth hosts the generic pane-preview attach path can fail
// host-reachability checks because the connection is initiated as a plain
// TCP/SSH dial rather than through the Tailscale transport that was used for
// the interactive terminal. Building the hostConfig with an explicit
// authType and a defensively derived SSH port ensures the backend selects
// the correct Tailscale-aware PTY path for both initial attach and reattach.
let terminalHostConfig: TerminalHostConfig;
if (host.authType === "tailscale") {
// Prefer host.sshPort when set (Tailscale SSH can be on a non-standard
// port); fall back to the general host.port.
const resolvedPort = host.sshPort ?? host.port;
terminalHostConfig = {
...host,
port: resolvedPort,
sshPort: resolvedPort,
// Carry authType explicitly to guard against accidental omission
// in the spread (e.g. if host object shape changes upstream).
authType: "tailscale",
instanceId: instanceIdRef.current,
} as TerminalHostConfig;
} else {
const resolvedPort = host.sshPort ?? host.port;
terminalHostConfig = {
...host,
port: resolvedPort,
sshPort: resolvedPort,
instanceId: instanceIdRef.current,
} as TerminalHostConfig;
}
// The host's own authType travels in the spread, so a Tailscale host still
// gets the Tailscale-aware PTY path. The SSH port may be non-standard.
const resolvedPort = host.sshPort ?? host.port;
const terminalHostConfig = {
...host,
port: resolvedPort,
sshPort: resolvedPort,
instanceId: instanceIdRef.current,
} as TerminalHostConfig;
return (
<>
+23 -3
View File
@@ -15,7 +15,10 @@ import { usePermissions } from "@/hooks/use-permissions";
* dropped entirely rather than disabled, which is how admin-gated UI already
* behaves elsewhere in the app.
*/
export function useActionSlot(slotId: string): SlotContribution[] {
export function useActionSlot(
slotId: string,
context?: Record<string, unknown>,
): SlotContribution[] {
const contributions = useSyncExternalStore(
subscribeToActionRegistry,
() => getSlotContributions(slotId),
@@ -31,7 +34,24 @@ export function useActionSlot(slotId: string): SlotContribution[] {
return contributions.filter((contribution) => {
const permission =
getActionPermission(contribution.actionId) ?? undefined;
return !permission || has(permission);
if (permission && !has(permission)) return false;
if (!contribution.when) return true;
try {
return contribution.when(context ?? {});
} catch {
return false;
}
});
}, [contributions, has, loaded]);
// context is compared by value: slot owners build it inline.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contributions, has, loaded, contextKey(context)]);
}
function contextKey(context: Record<string, unknown> | undefined): string {
if (!context) return "";
try {
return JSON.stringify(context);
} catch {
return String(Object.keys(context));
}
}
-68
View File
@@ -1,68 +0,0 @@
import { useEffect, useState } from "react";
export interface AiAvailability {
/** Admin kill switch. When false the feature does not exist for anyone. */
globallyEnabled: boolean;
/** Global switch and the user's own opt-in, which is what the rail needs. */
userEnabled: boolean;
/** False until the status call answers, so nothing flashes in or out. */
loaded: boolean;
}
/** Fired when the AI status may have changed, so every surface re-reads it. */
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
export function notifyAiStatusChanged(): void {
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
}
/**
* One source of truth for whether any AI surface should render.
*
* The admin global is a hard kill switch: when it is off the assistant is
* hidden everywhere for everyone, including the tab bar, command palette,
* mobile bar, and the navigation visibility toggles, so nobody can reach or
* even see an entry the server would refuse anyway.
*/
export function useAiAvailability(): AiAvailability {
const [state, setState] = useState<AiAvailability>({
globallyEnabled: false,
userEnabled: false,
loaded: false,
});
useEffect(() => {
let cancelled = false;
const refresh = () => {
import("@/api/ai-api")
.then(({ getAiStatus }) => getAiStatus())
.then((status) => {
if (cancelled) return;
setState({
globallyEnabled: status.globallyEnabled,
userEnabled: status.globallyEnabled && status.enabled,
loaded: true,
});
})
.catch(() => {
if (!cancelled)
setState({
globallyEnabled: false,
userEnabled: false,
loaded: true,
});
});
};
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
// The profile toggle and onboarding still fire this older event.
window.addEventListener("hiddenRailTabsChanged", refresh);
return () => {
cancelled = true;
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
};
}, []);
return state;
}
+10
View File
@@ -102,3 +102,13 @@ export function resetPermissionsCache(): void {
cache = null;
inFlight = null;
}
/** Test seam: answers every hook with these grants, no request made. */
export function setPermissionsForTesting(
permissions: string[],
isAdmin = false,
): void {
cache = { permissions, isAdmin };
inFlight = null;
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
}
+92 -37
View File
@@ -6,46 +6,83 @@ import enTranslation from "../locales/en.json";
type LocaleModule = { default: ResourceKey };
const localeLoaders = {
af: () => import("../locales/translated/af_ZA.json"),
ar: () => import("../locales/translated/ar_SA.json"),
bn: () => import("../locales/translated/bn_BD.json"),
bg: () => import("../locales/translated/bg_BG.json"),
ca: () => import("../locales/translated/ca_ES.json"),
cs: () => import("../locales/translated/cs_CZ.json"),
da: () => import("../locales/translated/da_DK.json"),
de: () => import("../locales/translated/de_DE.json"),
el: () => import("../locales/translated/el_GR.json"),
"es-ES": () => import("../locales/translated/es_ES.json"),
fi: () => import("../locales/translated/fi_FI.json"),
fr: () => import("../locales/translated/fr_FR.json"),
he: () => import("../locales/translated/he_IL.json"),
hi: () => import("../locales/translated/hi_IN.json"),
hu: () => import("../locales/translated/hu_HU.json"),
id: () => import("../locales/translated/id_ID.json"),
it: () => import("../locales/translated/it_IT.json"),
ja: () => import("../locales/translated/ja_JP.json"),
ko: () => import("../locales/translated/ko_KR.json"),
nl: () => import("../locales/translated/nl_NL.json"),
no: () => import("../locales/translated/no_NO.json"),
pl: () => import("../locales/translated/pl_PL.json"),
"pt-PT": () => import("../locales/translated/pt_PT.json"),
"pt-BR": () => import("../locales/translated/pt_BR.json"),
ro: () => import("../locales/translated/ro_RO.json"),
ru: () => import("../locales/translated/ru_RU.json"),
sr: () => import("../locales/translated/sr_SP.json"),
"sv-SE": () => import("../locales/translated/sv_SE.json"),
th: () => import("../locales/translated/th_TH.json"),
tr: () => import("../locales/translated/tr_TR.json"),
uk: () => import("../locales/translated/uk_UA.json"),
vi: () => import("../locales/translated/vi_VN.json"),
"zh-CN": () => import("../locales/translated/zh_CN.json"),
"zh-TW": () => import("../locales/translated/zh_TW.json"),
} satisfies Record<string, () => Promise<LocaleModule>>;
/** i18n code -> translated file name, shared by core and plugin locales. */
export const LOCALE_FILES = {
af: "af_ZA",
ar: "ar_SA",
bn: "bn_BD",
bg: "bg_BG",
ca: "ca_ES",
cs: "cs_CZ",
da: "da_DK",
de: "de_DE",
el: "el_GR",
"es-ES": "es_ES",
fi: "fi_FI",
fr: "fr_FR",
he: "he_IL",
hi: "hi_IN",
hu: "hu_HU",
id: "id_ID",
it: "it_IT",
ja: "ja_JP",
ko: "ko_KR",
nl: "nl_NL",
no: "no_NO",
pl: "pl_PL",
"pt-PT": "pt_PT",
"pt-BR": "pt_BR",
ro: "ro_RO",
ru: "ru_RU",
sr: "sr_SP",
"sv-SE": "sv_SE",
th: "th_TH",
tr: "tr_TR",
uk: "uk_UA",
vi: "vi_VN",
"zh-CN": "zh_CN",
"zh-TW": "zh_TW",
} as const satisfies Record<string, string>;
const translatedFiles = import.meta.glob<LocaleModule>(
"../locales/translated/*.json",
);
const localeLoaders: Record<string, () => Promise<LocaleModule>> =
Object.fromEntries(
Object.entries(LOCALE_FILES).map(([code, file]) => [
code,
() => {
const load = translatedFiles[`../locales/translated/${file}.json`];
return load ? load() : Promise.reject(new Error(`no ${file}`));
},
]),
);
/**
* Loads a plugin namespace. Set by the plugin loader; until then a plugin
* namespace resolves to nothing and keys fall back to core.
*/
export type PluginLocaleResolver = (
namespace: string,
language: string,
file: string,
) => Promise<ResourceKey | null>;
let pluginLocaleResolver: PluginLocaleResolver | null = null;
export function setPluginLocaleResolver(
resolver: PluginLocaleResolver | null,
): void {
pluginLocaleResolver = resolver;
}
export const supportedLngs = ["en", ...Object.keys(localeLoaders)];
const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language";
/** Core strings. Each plugin gets a namespace named after its id. */
export const CORE_NAMESPACE = "translation";
export function normalizeLanguageCode(language?: string | null): string {
if (!language) return "en";
@@ -64,9 +101,24 @@ export function normalizeLanguageCode(language?: string | null): string {
const localeBackend: BackendModule = {
type: "backend",
init: () => {},
read: (language, _namespace, callback) => {
read: (language, namespace, callback) => {
const normalizedLanguage = normalizeLanguageCode(language);
if (namespace !== CORE_NAMESPACE) {
const file =
normalizedLanguage === "en"
? "en"
: (LOCALE_FILES as Record<string, string>)[normalizedLanguage];
if (!pluginLocaleResolver || !file) {
callback(null, {});
return;
}
pluginLocaleResolver(namespace, normalizedLanguage, file)
.then((resources) => callback(null, resources ?? {}))
.catch(() => callback(null, {}));
return;
}
if (normalizedLanguage === "en") {
callback(null, enTranslation);
return;
@@ -91,6 +143,9 @@ i18n
.init({
supportedLngs,
fallbackLng: "en",
// A plugin's namespace falls back to core, so plugins keep using the
// shared strings (common.*, hosts.*) without copying them.
fallbackNS: CORE_NAMESPACE,
debug: false,
detection: {
+37
View File
@@ -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 };
}
+26 -30
View File
@@ -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);
}
+51
View File
@@ -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;
}
+15 -7
View File
@@ -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}`;
}
+81
View File
@@ -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);
}
-6
View File
@@ -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",
+23 -1217
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5 -56
View File
@@ -754,6 +754,11 @@ export async function checkElectronUpdate(): Promise<ElectronUpdateCheckResult>
}
}
/** A URL on the main backend, resolved for web, dev proxy and Electron. */
export function getBackendUrl(path: string): string {
return getApiUrl(path, 30001);
}
function getApiUrl(path: string, defaultPort: number): string {
const devMode = isDev();
const electronMode = isElectron();
@@ -1661,26 +1666,6 @@ export {
notifyHostCreatedOrUpdated,
} from "@/api/host-metrics-status-api";
export {
getHostMetricsLayout,
saveHostMetricsLayout,
getHostPlatform,
managerGet,
managerGetSub,
managerPost,
type PlatformInfo,
} from "@/api/host-metrics-api";
export {
getProxmoxStats,
startProxmoxStatsPolling,
stopProxmoxStatsPolling,
sendProxmoxStatsHeartbeat,
getProxmoxStatsHistory,
type ProxmoxStatsHistoryRow,
type ProxmoxStatsHistoryResponse,
} from "@/api/proxmox-stats-api";
export {
getHostSidebarPreferences,
saveHostSidebarPreferences,
@@ -2211,17 +2196,6 @@ export {
unlinkOIDCFromPasswordAccount,
} from "@/api/oidc-account-api";
export type {
GuacamoleTokenRequest,
GuacamoleTokenResponse,
} from "../../plugins/remote-desktop/src/frontend/guacamole-api";
export {
getGuacamoleDpi,
getGuacamoleToken,
getGuacamoleTokenFromHost,
getGuacdStatus,
} from "../../plugins/remote-desktop/src/frontend/guacamole-api";
// ============================================================================
// RBAC MANAGEMENT
// ============================================================================
@@ -2255,31 +2229,6 @@ export type {
PermissionCatalogItem,
} from "@/api/rbac-api";
// ============================================================================
// DOCKER MANAGEMENT API
// ============================================================================
export {
connectDockerSession,
verifyDockerTOTP,
verifyDockerWarpgate,
disconnectDockerSession,
keepaliveDockerSession,
getDockerSessionStatus,
validateDockerAvailability,
listDockerContainers,
getDockerContainerDetails,
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
getContainerLogs,
downloadContainerLogs,
getContainerStats,
} from "@/api/docker-api";
export {
getOpenTabs,
syncOpenTabs,
+25 -2
View File
@@ -10,7 +10,15 @@ import {
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { relevantSteps, type OnboardingContext } from "./onboarding-steps";
import { useActionSlot } from "@/hooks/use-action-slot";
import {
relevantSteps,
type OnboardingContext,
type OnboardingStep,
} from "./onboarding-steps";
/** Plugin steps go in before this one. */
const PLUGIN_STEPS_BEFORE = "security";
export function OnboardingDialog({
open,
@@ -23,7 +31,22 @@ export function OnboardingDialog({
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const steps = useMemo(() => relevantSteps(context), [context]);
const pluginSteps = useActionSlot("onboarding.steps");
const steps = useMemo(() => {
const core = relevantSteps(context);
const extra: OnboardingStep[] = pluginSteps
.filter((contribution) => contribution.component)
.map((contribution) => ({
id: contribution.actionId,
titleKey: contribution.titleKey,
Component:
contribution.component as unknown as OnboardingStep["Component"],
}));
const at = core.findIndex((step) => step.id === PLUGIN_STEPS_BEFORE);
return at < 0
? [...core, ...extra]
: [...core.slice(0, at), ...extra, ...core.slice(at)];
}, [context, pluginSteps]);
const [index, setIndex] = useState(0);
// A reopened run always starts from the top.
+4 -14
View File
@@ -5,13 +5,10 @@ import { AppearanceStep } from "./steps/AppearanceStep";
import { FeaturesStep } from "./steps/FeaturesStep";
import { WorkflowStep } from "./steps/WorkflowStep";
import { SecurityStep } from "./steps/SecurityStep";
import { AiAssistantStep } from "./steps/AiAssistantStep";
import { DoneStep } from "./steps/DoneStep";
export interface OnboardingContext {
/** Whether an admin has enabled the AI assistant for this instance. */
aiGloballyEnabled: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface OnboardingContext {}
export interface OnboardingStepProps {
context: OnboardingContext;
@@ -27,7 +24,8 @@ export interface OnboardingStep {
/**
* Onboarding as data rather than hardcoded JSX, so steps can be added,
* reordered or made conditional without touching the dialog shell.
* reordered or made conditional without touching the dialog shell. Plugins
* add steps through the "onboarding.steps" slot; they land before security.
*/
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
@@ -51,14 +49,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
titleKey: "onboarding.workflowTitle",
Component: WorkflowStep,
},
{
id: "ai",
titleKey: "onboarding.aiTitle",
Component: AiAssistantStep,
// Never shown when the admin has the assistant switched off, so users on
// an instance without it are not told about a feature they cannot use.
isRelevant: (context) => context.aiGloballyEnabled,
},
{
id: "security",
titleKey: "onboarding.securityTitle",
@@ -1,97 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check } from "lucide-react";
import { saveUserPreferences } from "@/main-axios";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
/**
* Asks once, plainly.
*
* "Leave it off" is preselected and applied on mount, so someone who clicks
* straight through ends up without the assistant rather than with it. Both
* options are described in the same flat register and neither is marked
* recommended: this is a choice, not a pitch.
*/
export function AiAssistantStep() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
function apply(next: boolean) {
setEnabled(next);
const hidden = readHiddenRailTabs();
if (next) hidden.delete("ai");
else hidden.add("ai");
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
saveUserPreferences({
aiAssistantEnabled: next,
hiddenRailTabs: serialized,
}).catch(() => {
// The local choice still applies if the write fails.
});
}
// Skipping the step entirely still has to mean off, not unset.
useEffect(() => {
apply(false);
}, []);
const options = [
{
value: false,
title: t("onboarding.aiSkipTitle"),
description: t("onboarding.aiSkipDesc"),
},
{
value: true,
title: t("onboarding.aiEnableTitle"),
description: t("onboarding.aiEnableDesc"),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">{t("onboarding.aiIntro")}</p>
<div className="flex flex-col gap-2">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => apply(option.value)}
className={`flex items-start gap-2.5 border p-2.5 text-left ${
enabled === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card"
}`}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium">{option.title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{option.description}
</span>
</div>
{/*
Always rendered and only made visible when selected, so picking an
option never reflows the text beside it.
*/}
<Check
size={14}
className={`ml-auto mt-0.5 shrink-0 ${
enabled === option.value ? "opacity-100" : "opacity-0"
}`}
/>
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/70">
{t("onboarding.aiNote")}
</p>
</div>
);
}
Loaded 100 of 721 files, more files were not shown because too many files have changed in this diff. Show more