chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+1 -1
View File
@@ -2744,7 +2744,7 @@ export function AppShell({
onOpenFileManager={(tabId) => {
const targetTab = tabs.find((t) => t.id === tabId);
if (targetTab?.host) {
void invokeAction("files.openHost", targetTab.host);
void invokeAction("host.openFiles", targetTab.host);
}
}}
isAppFullscreen={isAppFullscreen}
-60
View File
@@ -190,63 +190,3 @@ export async function adminDeleteUserCredential(
throw handleApiError(error, "delete user's credential");
}
}
export async function adminGetUserSnippets(
targetUserId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/plugin-api/snippets", {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user's snippets");
}
}
export async function adminCreateUserSnippet(
targetUserId: string,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/plugin-api/snippets", snippetData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet for user");
}
}
export async function adminUpdateUserSnippet(
targetUserId: string,
snippetId: number,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(
`/plugin-api/snippets/${snippetId}`,
snippetData,
{
headers: adminHeaders(targetUserId),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "update user's snippet");
}
}
export async function adminDeleteUserSnippet(
targetUserId: string,
snippetId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/plugin-api/snippets/${snippetId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete user's snippet");
}
}
+2
View File
@@ -153,6 +153,8 @@ export interface SshAuthProviderSummary {
credentialType: boolean;
needsUserInteraction: boolean;
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect?: boolean;
available: boolean;
missingPlugin?: { id: string; name: string };
}
-42
View File
@@ -1,42 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
/**
* A snippet row as the list endpoint returns it. Callers that need the full
* shape narrow it themselves; only the id is relied on across the app.
*
* Snippets themselves are owned by the snippets plugin
* (/plugin-api/snippets); this is the thin read/run surface used by a few
* core and plugin callers (the host editor's startup snippet, the command
* palette, custom keybindings, host-metrics quick actions) that cannot
* reach into the snippets plugin's own frontend code.
*/
export interface SnippetRow {
id: number;
[key: string]: unknown;
}
export async function getSnippets(): Promise<SnippetRow[]> {
try {
const response = await authApi.get("/plugin-api/snippets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippets");
}
}
export async function executeSnippet(
snippetId: number,
hostId: number,
inputValues?: Record<string, string>,
): Promise<{ success: boolean; output: string; error?: string }> {
try {
const response = await authApi.post("/plugin-api/snippets/execute", {
snippetId,
hostId,
...(inputValues ? { inputValues } : {}),
});
return response.data;
} catch (error) {
throw handleApiError(error, "execute snippet");
}
}
+1 -91
View File
@@ -1,13 +1,7 @@
import { AxiosError } from "axios";
import {
authApi,
getAllServerStatuses,
handleApiError,
sshHostApi,
} from "@/main-axios";
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxDiscoverResult, ProxmoxSyncResult } from "@/types/proxmox";
import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
@@ -174,90 +168,6 @@ export async function importSSHConfigHosts(
}
}
export async function discoverProxmoxGuests(
hostId: number,
): Promise<ProxmoxDiscoverResult> {
try {
const response = await authApi.post(
"/plugin-api/proxmox/discover",
{ hostId },
{ timeout: 120000 },
);
return response.data;
} catch (error) {
handleApiError(error, "discover Proxmox guests");
}
}
export function discoverProxmoxGuestsStream(
hostId: number,
handlers: {
onProgress?: (done: number, total: number) => void;
onResult: (result: ProxmoxDiscoverResult) => void;
onError: (message: string) => void;
},
): () => void {
const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, "");
const source = new EventSource(
`${baseURL}/plugin-api/proxmox/discover/stream?hostId=${encodeURIComponent(
String(hostId),
)}`,
{ withCredentials: true },
);
let settled = false;
const close = () => {
settled = true;
source.close();
};
source.addEventListener("progress", (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
handlers.onProgress?.(data.done, data.total);
} catch {
// ignore malformed progress frames
}
});
source.addEventListener("result", (event) => {
close();
try {
handlers.onResult(JSON.parse((event as MessageEvent).data));
} catch {
handlers.onError("Failed to parse discovery result");
}
});
source.addEventListener("fail", (event) => {
close();
let message = "Discovery failed";
try {
message = JSON.parse((event as MessageEvent).data).message || message;
} catch {
// keep default message
}
handlers.onError(message);
});
source.onerror = () => {
if (settled) return;
close();
handlers.onError("Discovery connection lost");
};
return close;
}
export async function syncProxmoxGuests(
hostId: number,
): Promise<ProxmoxSyncResult> {
try {
const response = await authApi.post(
"/plugin-api/proxmox/sync",
{ hostId },
{ timeout: 120000 },
);
return response.data;
} catch (error) {
handleApiError(error, "sync Proxmox guests");
}
}
export async function bulkUpdateSSHHosts(
hostIds: number[],
updates: Record<string, unknown>,
+4 -4
View File
@@ -58,7 +58,7 @@ import {
} from "lucide-react";
import { Input } from "@/components/input";
export const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
folder: Folder,
"folder-open": FolderOpen,
"folder-git": FolderGit2,
@@ -113,12 +113,12 @@ export const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
smartphone: Smartphone,
};
export const FOLDER_ICON_IDS = Object.keys(FOLDER_ICON_MAP);
const FOLDER_ICON_IDS = Object.keys(FOLDER_ICON_MAP);
export const DEFAULT_FOLDER_ICON = "folder";
export const DEFAULT_FOLDER_COLOR = "#f59145";
export const SUGGESTED_FOLDER_COLORS = [
const SUGGESTED_FOLDER_COLORS = [
"#f59145",
"#ef4444",
"#eab308",
@@ -146,7 +146,7 @@ export function FolderIconEl({
const HEX_RE = /^#([0-9a-fA-F]{6})$/;
export function isValidHex(value: string): boolean {
function isValidHex(value: string): boolean {
return HEX_RE.test(value.trim());
}
-668
View File
@@ -1,668 +0,0 @@
"use client";
import React, {
useState,
useCallback,
createContext,
useContext,
useRef,
useEffect,
} from "react";
import {
motion,
AnimatePresence,
easeInOut,
type Variants,
} from "motion/react";
import {
ChevronRight,
Folder,
FolderOpen,
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
rootAnimate: { opacity: 1, y: 0 },
itemInitial: { opacity: 0, x: -10 },
itemAnimate: { opacity: 1, x: 0 },
contentHidden: { opacity: 0, height: 0 },
contentVisible: { opacity: 1, height: "auto" },
chevronClosed: { rotate: 0 },
chevronOpen: { rotate: 90 },
};
const transitions = {
root: { duration: 0.4 },
item: { duration: 0.2 },
content: { duration: 0.3, ease: easeInOut },
chevron: { duration: 0.2 },
};
interface ExpansionContextType {
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
interface SelectionContextType {
selectedId: string | null;
setSelected: (id: string) => void;
onSelect?: (id: string, label: string) => void;
}
interface TreeContextType {
focusedId: string | null;
setFocusedId: (id: string | null) => void;
treeId: string;
setKeyboardMode: (mode: boolean) => void;
keyboardMode: boolean;
}
interface LevelContextType {
level: number;
}
const ExpansionContext = createContext<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ level: 0 });
const useExpansion = () => {
const context = useContext(ExpansionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useLevel = () => {
return useContext(LevelContext);
};
const getPaddingClass = (level: number): string => {
const paddingMap: Record<number, string> = {
0: "pl-3",
1: "pl-8",
2: "pl-12",
3: "pl-16",
4: "pl-20",
5: "pl-24",
6: "pl-28",
7: "pl-32",
};
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
};
interface CustomBadge {
content: React.ReactNode;
className?: string;
ariaLabel?: string;
}
interface RootProps {
defaultExpanded?: string[];
defaultSelected?: string;
selectedId?: string | null;
expandedIds?: Set<string>;
onSelect?: (id: string, label: string) => void;
className?: string;
children: React.ReactNode;
id?: string;
}
interface ItemProps {
id: string;
label: string;
icon?: LucideIcon;
badge?: string | number;
modified?: boolean | CustomBadge;
untracked?: boolean | CustomBadge;
className?: string;
children?: React.ReactNode;
}
interface TriggerProps {
className?: string;
}
interface ContentProps {
children: React.ReactNode;
className?: string;
}
const Root: React.FC<RootProps> = ({
defaultExpanded = [],
defaultSelected,
selectedId: controlledSelectedId,
expandedIds: additionalExpandedIds,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded),
);
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
defaultSelected || null,
);
const selectedId =
controlledSelectedId !== undefined
? controlledSelectedId
: internalSelectedId;
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
setExpandedIds((prev) => {
const merged = new Set(prev);
let changed = false;
for (const id of additionalExpandedIds) {
if (!merged.has(id)) {
merged.add(id);
changed = true;
}
}
return changed ? merged : prev;
});
}, [additionalExpandedIds]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const setSelected = useCallback((id: string) => {
setInternalSelectedId(id);
}, []);
const getVisibleItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
})
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const [treeHasFocus, setTreeHasFocus] = useState(false);
const handleTreeFocus = useCallback(() => {
if (!treeHasFocus) {
setTreeHasFocus(true);
setKeyboardMode(true);
}
}, [treeHasFocus]);
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
setTreeHasFocus(false);
setFocusedId(null);
setKeyboardMode(false);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const getVisibleItems = () => {
return Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
).filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
});
};
if (e.key === "Tab") {
if (treeHasFocus && !focusedId) {
const visibleItemIds = getVisibleItemIds();
if (visibleItemIds.length > 0) {
setFocusedId(visibleItemIds[0]);
e.preventDefault();
return;
}
}
if (focusedId) {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
if (e.shiftKey) {
if (currentIndex === 0) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.max(0, currentIndex - 1);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
} else {
if (currentIndex === visibleItems.length - 1) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.min(
visibleItems.length - 1,
currentIndex + 1,
);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
}
}
return;
}
if (!keyboardMode || !focusedId) return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
const nextId = nextItem.getAttribute("data-id");
if (nextId) setFocusedId(nextId);
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
const prevId = prevItem.getAttribute("data-id");
if (prevId) setFocusedId(prevId);
}
break;
case "ArrowRight":
e.preventDefault();
if (!expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "ArrowLeft":
e.preventDefault();
if (expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "Enter":
case " ":
e.preventDefault();
setSelected(focusedId);
if (onSelect) {
const currentItem = visibleItems[currentIndex] as HTMLElement;
const label =
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
"";
onSelect(focusedId, label);
}
break;
}
},
[
focusedId,
keyboardMode,
expandedIds,
toggleExpanded,
setSelected,
onSelect,
getVisibleItemIds,
treeHasFocus,
],
);
useEffect(() => {
const handleMouseDown = () => setKeyboardMode(false);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
const expansionValue: ExpansionContextType = {
expandedIds,
toggleExpanded,
};
const selectionValue: SelectionContextType = {
selectedId,
setSelected,
onSelect,
};
const treeValue: TreeContextType = {
focusedId,
setFocusedId,
treeId: id,
setKeyboardMode,
keyboardMode,
};
return (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-canvas border border-edge rounded-lg overflow-hidden",
className,
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-canvas text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(null);
const keyboardMode = treeContext.keyboardMode;
const hasChildren = React.Children.count(children) > 0;
const isExpanded = expansionContext.expandedIds.has(id);
const isSelected = selectionContext.selectedId === id;
const isFocused = treeContext.focusedId === id;
const handleItemClick = useCallback(() => {
treeContext.setKeyboardMode(false);
selectionContext.setSelected(id);
treeContext.setFocusedId(id);
if (selectionContext.onSelect) {
selectionContext.onSelect(id, label);
}
}, [id, label, selectionContext, treeContext]);
const toggleExpanded = useCallback(() => {
if (hasChildren) {
expansionContext.toggleExpanded(id);
}
}, [id, hasChildren, expansionContext]);
const handleFocus = useCallback(() => {
treeContext.setFocusedId(id);
}, [id, treeContext]);
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.focus();
}
}, [isFocused]);
const IconComponent =
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
const itemContextValue = {
itemId: id,
hasChildren,
isExpanded,
toggleExpanded,
};
const renderBadge = (
badgeData: boolean | CustomBadge | undefined,
defaultContent: string,
defaultClassName: string,
) => {
if (!badgeData) return null;
if (typeof badgeData === "boolean") {
return (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
badgeData.className,
)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-accent text-accent-foreground border-r-2 border-ring"
: "",
!isSelected && "hover:bg-hover",
keyboardMode && isFocused
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
: "focus:outline-hidden",
)}
onClick={(e: React.MouseEvent) => {
handleItemClick();
e.stopPropagation();
toggleExpanded();
}}
onFocus={handleFocus}
role="treeitem"
tabIndex={isFocused ? 0 : -1}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
aria-level={level + 1}
>
{hasChildren && (
<motion.span
className="shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;
+2 -34
View File
@@ -14,7 +14,6 @@ import {
GripVertical,
KeyRound,
LayoutDashboard,
Network,
Plus,
Server,
Settings,
@@ -25,8 +24,6 @@ import {
import { Kbd } from "@/components/kbd";
import { VersionBadge } from "@/components/version-badge";
import { DASHBOARD_CARDS } from "@/lib/theme";
import { CONNECTION_STATES } from "@/types/index";
import { invokeAction } from "@/shell/action-registry";
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
import {
getSSHHosts,
@@ -232,17 +229,15 @@ function StatsBarCard({
function CountersBarCard({
hosts,
credentialCount,
activeTunnelCount,
onOpenSingletonTab,
}: {
hosts: Host[];
credentialCount: number;
activeTunnelCount: number;
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) {
const { t } = useTranslation();
return (
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
<Card className="grid grid-flow-col auto-cols-fr divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
<button
onClick={() => onOpenSingletonTab("host-manager")}
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
@@ -265,16 +260,7 @@ function CountersBarCard({
{t("dashboard.totalCredentials")}
</span>
</button>
<button
onClick={() => void invokeAction("tunnels.open")}
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
>
<Network className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{activeTunnelCount}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("dashboardTab.activeTunnels")}
</span>
</button>
<ComponentSlot slotId="dashboard.counters" />
</Card>
);
}
@@ -645,7 +631,6 @@ function CardItem({
releaseUrl,
dbHealth,
credentialCount,
activeTunnelCount,
activity,
onClearActivity,
isAdmin,
@@ -669,7 +654,6 @@ function CardItem({
releaseUrl: string;
dbHealth: "healthy" | "error";
credentialCount: number;
activeTunnelCount: number;
activity: RecentActivityItem[];
onClearActivity: () => void;
isAdmin: boolean;
@@ -740,7 +724,6 @@ function CardItem({
<CountersBarCard
hosts={hosts}
credentialCount={credentialCount}
activeTunnelCount={activeTunnelCount}
onOpenSingletonTab={onOpenSingletonTab}
/>
)}
@@ -881,7 +864,6 @@ type PanelColumnProps = {
releaseUrl: string;
dbHealth: "healthy" | "error";
credentialCount: number;
activeTunnelCount: number;
activity: RecentActivityItem[];
onClearActivity: () => void;
cardLabels: Record<DashboardCardId, string>;
@@ -910,7 +892,6 @@ function PanelColumn({
releaseUrl,
dbHealth,
credentialCount,
activeTunnelCount,
activity,
onClearActivity,
cardLabels,
@@ -965,7 +946,6 @@ function PanelColumn({
releaseUrl={releaseUrl}
dbHealth={dbHealth}
credentialCount={credentialCount}
activeTunnelCount={activeTunnelCount}
activity={activity}
onClearActivity={onClearActivity}
isAdmin={isAdmin}
@@ -1130,7 +1110,6 @@ export function DashboardTab({
const [releaseUrl, setReleaseUrl] = useState("");
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
const [credentialCount, setCredentialCount] = useState(0);
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
@@ -1186,15 +1165,6 @@ export function DashboardTab({
),
)
.catch(() => {});
// Undefined while the tunnels plugin is off, which reads as zero.
invokeAction("tunnels.statuses")
.then((statuses) => {
const active = Object.values(
(statuses ?? {}) as Record<string, { status?: string }>,
).filter((s) => s?.status === CONNECTION_STATES.CONNECTED).length;
setActiveTunnelCount(active);
})
.catch(() => {});
if (!isVisible) {
return () => {
@@ -1351,7 +1321,6 @@ export function DashboardTab({
releaseUrl,
dbHealth,
credentialCount,
activeTunnelCount,
activity,
onClearActivity: handleClearActivity,
onOpenSingletonTab,
@@ -1468,7 +1437,6 @@ export function DashboardTab({
<CountersBarCard
hosts={hosts}
credentialCount={credentialCount}
activeTunnelCount={activeTunnelCount}
onOpenSingletonTab={onOpenSingletonTab}
/>
)}
@@ -55,8 +55,8 @@ export function AlertManager({
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch {
toast.error(t("homepage.failedToLoadAlerts"));
setError(t("homepage.failedToLoadAlerts"));
toast.error(t("alerts.failedToLoad"));
setError(t("alerts.failedToLoad"));
} finally {
setLoading(false);
}
@@ -79,7 +79,7 @@ export function AlertManager({
return prevIndex;
});
} catch {
setError(t("homepage.failedToDismissAlert"));
setError(t("alerts.failedToDismiss"));
}
};
+1 -1
View File
@@ -10,7 +10,7 @@ export interface PermissionsState {
}
/** Fired when a user's grants may have changed, so every surface re-reads them. */
export const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
interface CachedPermissions {
permissions: string[];
+3 -3
View File
@@ -16,7 +16,7 @@ interface ResolvedForTerminal {
* Shared "run this snippet against these terminal tabs" flow, for the few
* core surfaces (the command palette) that still reach for a snippet
* directly rather than through the snippets plugin's own panel. Variable
* resolution runs through the snippets plugin's "snippets.resolveForTerminal"
* resolution runs through the snippets plugin's "snippet.resolveForTerminal"
* action, and sending runs through ssh-terminal's "terminal.sendToSession",
* since core no longer owns snippet content or a terminal ref.
*/
@@ -57,7 +57,7 @@ export function useSnippetRunner() {
inputValues: Record<string, string>,
) {
const resolved = (await invokeAction(
"snippets.resolveForTerminal",
"snippet.resolveForTerminal",
snippet.id,
tab.host ?? null,
inputValues,
@@ -95,7 +95,7 @@ export function useSnippetRunner() {
};
void invokeAction(
"snippets.resolveForTerminal",
"snippet.resolveForTerminal",
snippet.id,
targets[0]?.host ?? null,
).then((result) => {
+1
View File
@@ -17,6 +17,7 @@ function builtinSummary(type: string): SshAuthProviderSummary {
credentialType: type === "password" || type === "key",
needsUserInteraction: false,
supportsBackground: type !== "none",
quickConnect: true,
available: true,
};
}
-63
View File
@@ -1,63 +0,0 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
interface SimpleLoaderProps {
visible: boolean;
message?: string;
className?: string;
backgroundColor?: string;
}
export function SimpleLoader({
visible,
message,
className,
backgroundColor,
}: SimpleLoaderProps) {
if (!visible) {
return null;
}
return (
<>
<style>
{`
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.simple-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-base);
border-top-color: var(--foreground);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
`}
</style>
<div
className={cn(
"absolute inset-0 flex items-center justify-center z-[100]",
className,
)}
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
>
<div className="flex flex-col items-center gap-4">
<div className="simple-spinner"></div>
{message && (
<p className="text-sm text-foreground-secondary font-medium">
{message}
</p>
)}
</div>
</div>
</>
);
}
+29 -2
View File
@@ -6,7 +6,15 @@ import {
getCredentialSidebarPreferences,
saveCredentialSidebarPreferences,
} from "@/main-axios";
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
import {
PRESETS,
type UiPreset,
type UiRailPreferences,
} from "@/types/ui-preferences";
import {
listRegisteredRailItems,
type RailItemDef,
} from "@/sidebar/rail-items";
import { sanitizeHostSidebarPreferences } from "@/types/host-sidebar-preferences";
import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
import { getRegisteredDashboardCard } from "@/dashboard/dashboard-cards-registry";
@@ -68,6 +76,23 @@ function writeLocal(key: string, value: string) {
}
}
/**
* The rail views a preset hides: its own list, plus every plugin item that
* does not ask to stay when the preset hides plugin items.
*/
export function presetHiddenRailTabs(
rail: UiRailPreferences,
pluginItems: Pick<RailItemDef, "id" | "simplePreset" | "hideable">[],
): string[] {
if (!rail.hidePluginItems) return [...rail.hiddenTabs];
return [
...rail.hiddenTabs,
...pluginItems
.filter((item) => !item.simplePreset && item.hideable !== false)
.map((item) => item.id),
];
}
/**
* Writes a preset's values into the stores that own them. Safe to call when
* nothing changed; every write is idempotent.
@@ -87,7 +112,9 @@ export async function applyPresetSideEffects(
// Rail visibility lives on user_preferences and is mirrored to localStorage
// with a change event, the same way the settings toggles write it.
const hiddenRailTabs = JSON.stringify(target.rail.hiddenTabs);
const hiddenRailTabs = JSON.stringify(
presetHiddenRailTabs(target.rail, listRegisteredRailItems()),
);
writeLocal("hiddenRailTabs", hiddenRailTabs);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (isCloud) {
+9 -2
View File
@@ -373,7 +373,14 @@ export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
export const logger = systemLogger;
/** A named console logger, for plugins that want the same log format. */
export function createFrontendLogger(
serviceName: string,
serviceIcon = "🧩",
serviceColor = "#6b7280",
) {
return new FrontendLogger(serviceName, serviceIcon, serviceColor);
}
-4
View File
@@ -70,10 +70,6 @@ export function getCachedServerStatuses(
return statusCache.get(loader);
}
export function invalidateSSHHostsCache(): void {
hostsCache.invalidate();
}
export function invalidateServerStatusCache(): void {
statusCache.invalidate();
}
-22
View File
@@ -1,22 +0,0 @@
/**
* The shape an axios rejection actually has at the call sites that inspect it.
* A `catch` binding is `unknown`, and every one of these sites was reaching
* into it directly; this gives them one narrowing helper instead.
*/
export interface HttpLikeError {
message?: string;
code?: string;
response?: {
status?: number;
data?: {
error?: string;
message?: string;
code?: string;
fileNotFound?: boolean;
};
};
}
export function asHttpError(error: unknown): HttpLikeError {
return (error ?? {}) as HttpLikeError;
}
-82
View File
@@ -1,82 +0,0 @@
export interface KeyedRequestCache<T> {
get(
key: string,
loader: () => Promise<T>,
options?: { force?: boolean },
): Promise<T>;
peek(key: string, maxAgeMs?: number): T | null;
set(key: string, value: T): void;
invalidate(key?: string): void;
}
/** Bounded per-key cache with in-flight request coalescing. */
export function createKeyedRequestCache<T>(
ttlMs: number,
maxEntries = 100,
): KeyedRequestCache<T> {
const values = new Map<string, { value: T; storedAt: number }>();
const inflight = new Map<string, Promise<T>>();
const versions = new Map<string, number>();
const store = (key: string, value: T) => {
values.delete(key);
values.set(key, { value, storedAt: Date.now() });
while (values.size > Math.max(1, maxEntries)) {
const oldest = values.keys().next().value;
if (oldest === undefined) break;
values.delete(oldest);
}
};
return {
get(key, loader, options = {}) {
const cached = values.get(key);
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
return Promise.resolve(cached.value);
}
const pending = inflight.get(key);
if (pending) return pending;
const version = versions.get(key) ?? 0;
const request = loader()
.then((value) => {
if ((versions.get(key) ?? 0) === version) store(key, value);
return value;
})
.finally(() => {
if (inflight.get(key) === request) inflight.delete(key);
});
inflight.set(key, request);
return request;
},
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
const cached = values.get(key);
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
values.delete(key);
values.set(key, cached);
return cached.value;
},
set(key, value) {
versions.set(key, (versions.get(key) ?? 0) + 1);
inflight.delete(key);
store(key, value);
},
invalidate(key) {
if (key === undefined) {
for (const activeKey of inflight.keys()) {
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
}
values.clear();
inflight.clear();
} else {
versions.set(key, (versions.get(key) ?? 0) + 1);
values.delete(key);
inflight.delete(key);
}
},
};
}
-105
View File
@@ -1,105 +0,0 @@
// Thin, typed access to the desktop app's local filesystem bridge
// (electron/local-files.cjs via preload.js). Everything here resolves to
// "unavailable" outside Electron so callers can feature-detect cheaply.
import type {
LocalDirectoryListing,
LocalFsHomeInfo,
LocalFsResult,
LocalTrashResult,
LocalWalkResult,
} from "@/types/electron";
import { isElectron } from "./electron";
export function isLocalFileBrowserAvailable(): boolean {
if (!isElectron()) return false;
const api = window.electronAPI;
return !!api?.localFs && !!api?.localTransfer;
}
function requireLocalFs() {
const api = window.electronAPI?.localFs;
if (!api) {
throw new Error("Local file access is only available in the desktop app");
}
return api;
}
function unwrap<T>(result: LocalFsResult<T>): T {
if (!result || result.success !== true) {
const message =
result && "error" in result && result.error
? result.error
: "Local file operation failed";
const error = new Error(message) as Error & { code?: string };
if (result && "code" in result) error.code = result.code;
throw error;
}
return result;
}
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
return unwrap(await requireLocalFs().home());
}
export async function listLocalDirectory(
dirPath: string,
): Promise<LocalDirectoryListing> {
return unwrap(await requireLocalFs().list(dirPath));
}
export async function createLocalFolder(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
}
export async function createLocalFile(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
}
export async function renameLocalEntry(
oldPath: string,
newName: string,
): Promise<string> {
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
}
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
export async function trashLocalPaths(
paths: string[],
): Promise<LocalTrashResult> {
return unwrap(await requireLocalFs().trash(paths));
}
/** Subset of `paths` that already exist on disk. */
export async function localPathsExist(paths: string[]): Promise<string[]> {
if (paths.length === 0) return [];
return unwrap(await requireLocalFs().exists(paths)).existing;
}
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
export async function ensureLocalDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
}
export async function walkLocalPaths(
paths: string[],
): Promise<LocalWalkResult> {
return unwrap(await requireLocalFs().walk(paths));
}
export async function revealLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().reveal(targetPath));
}
export async function openLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().open(targetPath));
}
+1 -18
View File
@@ -31,7 +31,7 @@ import {
/** The backend port. Everything plugin-facing rides the main server. */
const BACKEND_PORT = 30001;
export function pluginApiPath(pluginId: string, path = ""): string {
function pluginApiPath(pluginId: string, path = ""): string {
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
return `/plugin-api/${pluginId}${suffix}`;
}
@@ -123,23 +123,6 @@ export function pluginApiFor(
return client;
}
/**
* pluginWsUrl for a path a backend handed out ("/plugin-ws/<id>/<path>?q"),
* so a caller can dial a plugin's socket without naming the plugin.
*/
export async function pluginWsUrlForPath(
wsPath: string,
options: { origin?: ConnectionOrigin } = {},
): Promise<WebSocketConnectionTarget | null> {
const [pathname, query] = wsPath.split("?", 2);
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
if (!match) return null;
const target = await pluginWsUrl(match[1], match[2], options);
if (!target || !query) return target;
const separator = target.url.includes("?") ? "&" : "?";
return { ...target, url: `${target.url}${separator}${query}` };
}
/**
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
* carry the JWT.
+1 -1
View File
@@ -23,7 +23,7 @@ export interface SharedHostConnectionAuth {
keyType?: string | null;
}
export async function getRemoteSharedHostConnectionAuth(
async function getRemoteSharedHostConnectionAuth(
localHostId: number,
): Promise<SharedHostConnectionAuth> {
if (localHostId >= 0) {
+22
View File
@@ -0,0 +1,22 @@
import { invokeAction } from "@/shell/action-registry";
/** A snippet as core's pickers show it. */
export interface SnippetRow {
id: number;
name: string;
content: string;
folder?: string | null;
isNote?: boolean;
}
/**
* The user's snippets, from whichever plugin provides them through the
* "snippet.list" action. Empty while no plugin does. An admin editing
* another user's host passes that user's id.
*/
export async function listSnippets(options?: {
targetUserId?: string;
}): Promise<SnippetRow[]> {
const rows = await invokeAction("snippet.list", options);
return Array.isArray(rows) ? (rows as SnippetRow[]) : [];
}
-5
View File
@@ -27,11 +27,6 @@ export function extractSnippetInputs(content: string): SnippetInput[] {
return Array.from(seen.values());
}
export function hasSnippetInputs(content: string): boolean {
INPUT_PATTERN.lastIndex = 0;
return INPUT_PATTERN.test(content);
}
function replaceVar(
content: string,
name: string,
-5
View File
@@ -5,11 +5,6 @@ export const splitDragState = { active: false };
// Callbacks registered by terminal instances to trigger a fit after drag ends.
const fitCallbacks = new Set<() => void>();
export function registerFitCallback(fn: () => void) {
fitCallbacks.add(fn);
return () => fitCallbacks.delete(fn);
}
export function notifyDragEnd() {
for (const fn of fitCallbacks) fn();
}
+5 -18
View File
@@ -1500,7 +1500,6 @@
"dashboardTab": {
"stable": "STABLE",
"hostsOnline": "Hosts Online",
"activeTunnels": "Active Tunnels",
"registerNewServer": "Register a new server",
"storeSshKeysOrPasswords": "Store SSH keys or passwords",
"manageUsersAndRoles": "Manage users and roles",
@@ -1742,7 +1741,6 @@
"manageTabAccount": "Account",
"manageTabHosts": "Hosts",
"manageTabCredentials": "Credentials",
"manageTabSnippets": "Snippets",
"manageTabSessions": "Sessions",
"manageTabDanger": "Danger",
"manageEditorBack": "Back to {{username}}'s data",
@@ -1779,18 +1777,6 @@
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
"credentialDeletedSuccess": "Credential deleted",
"credentialDeleteFailed": "Failed to delete credential",
"snippetsCount": "{{count}} snippets",
"addSnippetForUser": "Add Snippet",
"noSnippetsForUser": "This user has no snippets",
"snippetRequiredFields": "Snippet name and content are required",
"snippetNamePlaceholder": "Snippet name",
"snippetContentPlaceholder": "Command content",
"snippetFolderPlaceholder": "Folder (optional)",
"snippetSaved": "Snippet saved",
"snippetSaveFailed": "Failed to save snippet",
"deleteSnippetConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?",
"snippetDeletedSuccess": "Snippet deleted",
"snippetDeleteFailed": "Failed to delete snippet",
"noSessionsForUser": "No active sessions",
"deleteSuccessorLabel": "Hand over hosts and credentials to",
"deleteSuccessorMe": "Me (the deleting admin)",
@@ -1895,8 +1881,8 @@
"domainLabel": "Domain",
"domainPlaceholder": "optional",
"connectToTerminal": "Connect to Terminal",
"connectToFiles": "Connect to Files",
"connectWith": "Connect via {{protocol}}"
"connectWith": "Connect via {{protocol}}",
"connectToAction": "Connect to {{name}}"
},
"sshTools": {
"keyRecordingTitle": "Key Recording",
@@ -2150,7 +2136,6 @@
"uiArea_terminal": "Terminal",
"uiArea_fileManager": "File manager",
"uiArea_hostEditor": "Host editor",
"uiArea_homepage": "Homepage",
"sectionAppearance": "Appearance",
"sectionSecurity": "Security",
"sectionApiKeys": "API Keys",
@@ -2328,7 +2313,9 @@
"discordUrlInvalid": "Discord URL must be a valid Discord webhook URL",
"discordWebhookDesc": "POST a Discord embed to this webhook URL on each alert firing",
"discordUsername": "Display name (optional)",
"discordAvatar": "Avatar URL (optional)"
"discordAvatar": "Avatar URL (optional)",
"failedToLoad": "Failed to load alerts",
"failedToDismiss": "Failed to dismiss alert"
},
"macros": {
"title": "Macros",
+4 -445
View File
@@ -646,12 +646,10 @@ function getApiUrl(path: string, defaultPort: number): string {
// PER-HOST ORIGIN ROUTING (Electron desktop only)
// ============================================================================
//
// hostApi/fileManagerApi above always point at the
// embedded local backend -- they're the shared, always-on instances. When a
// host's connection origin resolves to "remote" (see
// src/ui/lib/connection-origin.ts), the backend that actually holds that
// host's live SSH session is the connected remote server instead, so file
// manager and stats calls for that host must follow it there.
// hostApi above always points at the embedded local backend. When a host's
// connection origin resolves to "remote" (see src/ui/lib/connection-origin.ts),
// the backend that holds that host's live session is the connected remote
// server instead, so calls for that host must follow it there.
//
// These dynamically-baseURL'd instances resolve the remote server's URL and
// JWT fresh on every request (cheap, and correct even if the user
@@ -695,18 +693,8 @@ export function createRemoteOriginApiInstance(path: string): AxiosInstance {
return instance;
}
let remoteFileManagerApi: AxiosInstance | null = null;
let remoteCoreApi: AxiosInstance | null = null;
export function getRemoteFileManagerApi(): AxiosInstance {
if (!remoteFileManagerApi) {
remoteFileManagerApi = createRemoteOriginApiInstance(
"/plugin-api/file-manager",
);
}
return remoteFileManagerApi;
}
/**
* The remote server's core routes, unprefixed.
*
@@ -719,46 +707,11 @@ export function getRemoteCoreApi(): AxiosInstance {
return remoteCoreApi;
}
// Maps a live SSH session (keyed by sessionId, which today is the host's
// numeric id as a string -- see ensureSSHSessionForHost) to the resolved
// origin it was connected through, so every subsequent file-manager call
// for that session reaches the backend that actually holds it.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getFileManagerApiForSession(sessionId: string): AxiosInstance {
return sessionOrigins.get(sessionId) === "remote"
? getRemoteFileManagerApi()
: fileManagerApi;
}
/** Which backend currently holds a live SSH session (see setSessionOrigin). */
export function getSessionOrigin(sessionId: string): "local" | "remote" {
return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local";
}
function initializeApiInstances() {
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
sshHostApi = hostApi;
// File Manager Operations API - the file-manager plugin, mounted on the
// main backend.
fileManagerApi = createApiInstance(
getApiUrl("/plugin-api/file-manager", 30001),
"FILE_MANAGER",
);
// Authentication API (port 30001)
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
@@ -771,9 +724,6 @@ export let hostApi: AxiosInstance;
// Backward compatibility
export let sshHostApi: AxiosInstance;
// File Manager Operations API (file-manager plugin)
export let fileManagerApi: AxiosInstance;
// Authentication API (port 30001)
export let authApi: AxiosInstance;
@@ -949,391 +899,12 @@ export function handleApiError(error: unknown, operation: string): never {
// ============================================================================
// ============================================================================
// HOST-TO-HOST TRANSFER
// ============================================================================
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
incompressibleRatio: number;
}
export interface TransferMethodPreview {
methodPreference: TransferMethodPreference;
resolvedMethod: "tar" | "item_sftp";
reasonKey: string;
sourcePlatform: "unix" | "windows";
destPlatform: "unix" | "windows";
sourceHasTar: boolean;
destHasTar: boolean;
summary: TransferScanSummary;
}
export async function getTransferMethodPreview(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
methodPreference?: TransferMethodPreference,
): Promise<TransferMethodPreview> {
try {
const response = await fileManagerApi.post("/transferMethodPreview", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
methodPreference: methodPreference ?? "auto",
});
return response.data;
} catch (error) {
handleApiError(error, "preview transfer method");
throw error;
}
}
export interface TransferHopMetrics {
id: string;
mbPerSec?: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function getTransferProgressPercent(
status: TransferProgressResponse,
): number | undefined {
if (
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0
) {
return Math.min(
100,
Math.round((status.bytesTransferred / status.totalBytes) * 100),
);
}
if (
status.itemsCompleted !== undefined &&
status.totalItems !== undefined &&
status.totalItems > 0
) {
return Math.min(
100,
Math.round((status.itemsCompleted / status.totalItems) * 100),
);
}
return undefined;
}
export function formatTransferMbPerSec(
mbPerSec?: number,
_bytes?: number,
_ms?: number,
): string {
if (mbPerSec === undefined || mbPerSec <= 0) return "";
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
return `${mbPerSec.toFixed(1)} MB/s`;
}
export function formatDurationMs(ms?: number): string {
if (ms === undefined) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
interface TransferProgressTracker {
update(status: TransferProgressResponse): {
rate: number | undefined;
stalled: boolean;
};
}
export function createTransferProgressTracker(): TransferProgressTracker {
let lastBytes: number | undefined;
let lastTime: number | undefined;
let lastRate: number | undefined;
let lastActivityTime: number | undefined;
const STALL_THRESHOLD_MS = 5000;
return {
update(status) {
const now = Date.now();
const bytes = status.bytesTransferred;
if (
bytes !== undefined &&
lastBytes !== undefined &&
lastTime !== undefined
) {
const deltaBytes = bytes - lastBytes;
const deltaMs = now - lastTime;
if (deltaMs > 0 && deltaBytes >= 0) {
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
if (deltaBytes > 0) lastActivityTime = now;
}
} else if (bytes !== undefined) {
lastActivityTime = now;
}
lastBytes = bytes;
lastTime = now;
const stalled =
lastActivityTime !== undefined &&
now - lastActivityTime > STALL_THRESHOLD_MS &&
status.status === "running" &&
status.phase === "transferring";
return { rate: lastRate, stalled };
},
};
}
export interface TransferProgressResponse {
transferId: string;
status: "running" | "success" | "partial" | "error" | "cancelled";
phase:
| "compressing"
| "transferring"
| "benchmarking"
| "verifying"
| "extracting"
| "reconnecting";
bytesTransferred?: number;
totalBytes?: number;
itemsCompleted?: number;
totalItems?: number;
failedPaths?: string[];
message?: string;
method?: "stream" | "tar" | "item_sftp" | "direct_rsync";
sourcePaths?: string[];
destPath?: string;
sourceSessionId?: string;
destSessionId?: string;
startedAt?: number;
timings?: TransferTimings;
sourceDeleted?: boolean;
moveRequested?: boolean;
partialDestRemaining?: boolean;
cleanupCompleted?: boolean;
retryable?: boolean;
integrityVerified?: boolean;
parallelSegmentCount?: number;
}
export async function transferToHost(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
move?: boolean,
methodPreference?: TransferMethodPreference,
parallelSegmentCount?: number,
): Promise<{ transferId: string }> {
try {
fileLogger.info("Starting host transfer", {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
move,
methodPreference,
parallelSegmentCount,
});
const response = await fileManagerApi.post("/transferToHost", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move,
methodPreference: methodPreference ?? "auto",
parallelSegmentCount,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to start host transfer", error, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
handleApiError(error, "transfer to host");
throw error;
}
}
export async function getTransferStatus(
transferId: string,
): Promise<TransferProgressResponse> {
try {
const response = await fileManagerApi.get(`/transferStatus/${transferId}`);
return response.data;
} catch (error) {
handleApiError(error, "get transfer status");
throw error;
}
}
export async function listActiveTransfers(): Promise<{
transfers: TransferProgressResponse[];
}> {
try {
const response = await fileManagerApi.get("/activeTransfers");
return response.data;
} catch (error) {
handleApiError(error, "list active transfers");
throw error;
}
}
export async function cancelTransferToHost(transferId: string): Promise<void> {
try {
await fileManagerApi.post(`/transferCancel/${transferId}`);
} catch (error) {
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
operation: "host_transfer",
transferId,
error,
});
}
}
export async function cleanupCancelledTransfer(
transferId: string,
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
try {
const response = await fileManagerApi.post(
`/transferCleanup/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "clean up cancelled transfer");
throw error;
}
}
export async function retryTransferToHost(
transferId: string,
): Promise<{ ok: boolean; transferId: string }> {
try {
const response = await fileManagerApi.post(`/transferRetry/${transferId}`);
return response.data;
} catch (error) {
handleApiError(error, "retry transfer");
throw error;
}
}
export async function pollTransferUntilComplete(
transferId: string,
onProgress?: (status: TransferProgressResponse) => void,
intervalMs = 500,
): Promise<TransferProgressResponse> {
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const status = await getTransferStatus(transferId);
onProgress?.(status);
if (
status.status === "success" ||
status.status === "partial" ||
status.status === "error" ||
status.status === "cancelled"
) {
resolve(status);
return;
}
setTimeout(poll, intervalMs);
} catch (err) {
reject(err);
}
};
void poll();
});
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
export interface TransferDestination {
id: number;
userId: string;
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string;
lastUsed?: string;
}
export async function getTransferRecent(
sourceHostId: number,
): Promise<TransferDestination[]> {
try {
const response = await authApi.get("/host/transfer/recent", {
params: { sourceHostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get transfer recent destinations");
throw error;
}
}
export async function addTransferRecent(
sourceHostId: number,
destHostId: number,
destPath: string,
destPathLabel?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/transfer/recent", {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
return response.data;
} catch (error) {
handleApiError(error, "add transfer recent destination");
throw error;
}
}
export {
getSSHHosts,
createSSHHost,
updateSSHHost,
bulkImportSSHHosts,
importSSHConfigHosts,
discoverProxmoxGuests,
discoverProxmoxGuestsStream,
syncProxmoxGuests,
bulkUpdateSSHHosts,
reorderSSHHosts,
deleteSSHHost,
@@ -1723,10 +1294,6 @@ export {
adminCreateUserCredential,
adminUpdateUserCredential,
adminDeleteUserCredential,
adminGetUserSnippets,
adminCreateUserSnippet,
adminUpdateUserSnippet,
adminDeleteUserSnippet,
} from "@/api/admin-user-data-api";
export {
@@ -1771,14 +1338,6 @@ export {
deployCredentialToHost,
} from "@/api/credentials-api";
// ============================================================================
// SNIPPETS API
// ============================================================================
// Snippets themselves are owned by the snippets plugin. This is the thin
// list read a few core pickers still need (see src/ui/api/snippets-api.ts).
export { getSnippets, executeSnippet } from "@/api/snippets-api";
// ============================================================================
export type { UptimeInfo, RecentActivityItem } from "@/api/dashboard-api";
export {
+1
View File
@@ -118,6 +118,7 @@ export function createPluginApp(
labelKey: key(item.titleKey),
kind: item.kind === "tab" ? "tab" : undefined,
hideable: item.hideable,
simplePreset: item.simplePreset,
promotable: item.promotable,
rightDockable: item.rightDockable,
mobilePrimary: item.mobilePrimary,
-7
View File
@@ -50,7 +50,6 @@ const loginMethods = createRegistry<LoginMethodDef>();
const secondFactors = createRegistry<SecondFactorDef>();
export const registerSshAuthEditor = sshAuthEditors.register;
export const getSshAuthEditor = sshAuthEditors.get;
export const useSshAuthEditors = sshAuthEditors.useList;
export const registerLoginMethod = loginMethods.register;
@@ -62,9 +61,3 @@ export const registerSecondFactor = secondFactors.register;
export const getSecondFactorUI = secondFactors.get;
export const useSecondFactors = secondFactors.useList;
export const listSecondFactorUIs = secondFactors.list;
export function resetAuthRegistries(): void {
sshAuthEditors.reset();
loginMethods.reset();
secondFactors.reset();
}
+19 -8
View File
@@ -30,7 +30,8 @@ import {
} from "@/api/plugins-api";
import { getCookie, getUserInfo } from "@/main-axios";
import { logActivity } from "@/api/dashboard-api";
import { getHostPassword } from "@/api/credentials-api";
import { getCredentials, getHostPassword } from "@/api/credentials-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import {
getUserPreferences,
parseCustomKeybindings,
@@ -51,8 +52,8 @@ import { useOptionalHostStatusEntry } from "@/lib/ServerStatusContext";
/**
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
* manifest module, which is not imported here because it pulls semver into
* the browser bundle. A group that is itself a plugin id (snippets, once)
* does not need an entry here: knownPluginIds() below already covers it.
* the browser bundle. A plugin's own namespace is covered by knownPluginIds()
* below.
*/
const CORE_PERMISSION_GROUPS = ["hosts", "credentials", "admin"];
@@ -119,11 +120,6 @@ function loadCurrentUser() {
return currentUser;
}
/** Forgets the cached user, on logout. */
export function resetBridgeUser(): void {
currentUser = null;
}
const SETTINGS_READERS: Record<
SettingsScope,
(pluginId: string, hostId?: number) => Promise<Record<string, unknown>>
@@ -295,6 +291,7 @@ export const pluginHostBridge: PluginHostBridge = {
pluginId: option.pluginId,
credentialType: option.credentialType,
supportsBackground: option.supportsBackground,
quickConnect: option.quickConnect === true,
})),
};
},
@@ -367,6 +364,20 @@ export const pluginHostBridge: PluginHostBridge = {
parseCustomKeybindings((await getUserPreferences()).customKeybindings),
setHostAutoTmux: (hostId, autoTmux) => setHostAutoTmux(hostId, autoTmux),
getClientPreference: (name) => getCookie(name),
listHosts: async () =>
(await getSSHHosts({ includeStatus: false })).map(toHostRecord),
listCredentials: async () => {
const raw = await getCredentials();
const list = Array.isArray(raw)
? raw
: ((raw as { credentials?: unknown[] }).credentials ?? []);
return (list as Record<string, unknown>[]).map((c) => ({
id: Number(c.id),
name: String(c.name ?? ""),
username: typeof c.username === "string" ? c.username : undefined,
authType: typeof c.authType === "string" ? c.authType : undefined,
}));
},
},
};
-10
View File
@@ -40,10 +40,6 @@ export function registerPluginComponent(
};
}
export function getPluginComponent(id: string): AnyComponent | undefined {
return components.get(id);
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -72,9 +68,3 @@ export function PluginComponent({
if (!Component) return fallback as ReactNode;
return createElement(Component, props);
}
/** Test seam. */
export function resetPluginComponents(): void {
components.clear();
notify();
}
@@ -26,8 +26,6 @@ export interface RegisteredHomepageWidget {
const registry = createRegistry<RegisteredHomepageWidget>();
export const registerHomepageWidgetType = registry.register;
export const unregisterHomepageWidgetType = registry.unregister;
export const getHomepageWidgetType = registry.get;
export const listHomepageWidgetTypes = registry.list;
export const useHomepageWidgetTypes = registry.useList;
export const resetHomepageWidgetTypes = registry.reset;
+1 -1
View File
@@ -327,7 +327,7 @@ export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
}
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
let lastFocusSync = 0;
const FOCUS_SYNC_INTERVAL_MS = 30_000;
+1 -1
View File
@@ -38,7 +38,7 @@ function commit(next: StoreState): void {
for (const listener of listeners) listener();
}
export function subscribePluginStore(listener: () => void): () => void {
function subscribePluginStore(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
+1 -1
View File
@@ -75,7 +75,7 @@ class PluginErrorBoundary extends Component<BoundaryProps, { error: boolean }> {
}
}
export function PluginScope({
function PluginScope({
pluginId,
children,
}: {
+37
View File
@@ -159,3 +159,40 @@ export {
testNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";
// Keyboard hints, the connection log panel and a named console logger.
export { Kbd, KbdKey, KbdSeparator } from "@/components/kbd";
export { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel";
export type { LogEntry } from "@/types/connection-log";
export { createFrontendLogger, type LogContext } from "@/lib/frontend-logger";
// One budget for background work across the whole app (preloads, prefetches).
export {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
// The id this browser sends as X-Termix-Device-ID.
export { getDeviceId } from "@/lib/device-id";
// A host that is never saved, for connecting to an address straight away.
export {
createQuickConnectHost,
isQuickConnectHost,
} from "@/sidebar/quick-connect-host";
// The desktop app's local file bridge (window.electronAPI.localFs and
// localTransfer), for plugins that browse or transfer local files.
export type {
LocalCollectedFile,
LocalDirectoryListing,
LocalFileEntry,
LocalFsHomeInfo,
LocalFsResult,
LocalTransferOrigin,
LocalTransferProgress,
LocalTrashResult,
LocalWalkFile,
LocalWalkResult,
} from "@/types/electron";
-4
View File
@@ -59,10 +59,6 @@ export function useShellHosts(): { hosts: Host[]; loaded: boolean } {
);
}
export function getShellHosts(): Host[] {
return hosts;
}
export function notifyTabsChanged(): void {
for (const listener of changeListeners) listener();
}
+9 -2
View File
@@ -9,7 +9,10 @@
import type { ReactElement } from "react";
import { render } from "@testing-library/react";
import type { PluginManifest } from "@termix/plugin-sdk/frontend";
import type {
HostActionContribution,
PluginManifest,
} from "@termix/plugin-sdk/frontend";
import type {
FrontendPluginModule,
RenderWithAppOptions,
@@ -27,7 +30,7 @@ import {
} from "@/shell/tab-registry";
import { getPanel, listPanels } from "@/shell/panel-registry";
import { listRegisteredRailItems } from "@/sidebar/rail-items";
import { listHostActions } from "@/sidebar/host-contributions";
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
import { listHostProtocols } from "@/sidebar/host-protocols";
import {
getHostEditorSection,
@@ -214,6 +217,10 @@ export async function renderPlugin(
id: action.id,
tabType: action.tabType,
})),
hostActionsFor: (host) =>
mine(
hostActionsFor(listHostActions(), host as Host),
) as unknown as HostActionContribution[],
hostProtocols: () =>
mine(listHostProtocols()).map((protocol) => protocol.id),
hostEditorSections: () =>
-6
View File
@@ -61,12 +61,6 @@ export function listSettingsComponents(): string[] {
/** Re-renders when components are registered, e.g. after a plugin loads. */
export const useSettingsComponents = registry.useList;
export function unregisterSettingsComponents(pluginId: string): void {
for (const entry of registry.list()) {
if (entry.id.startsWith(`${pluginId}:`)) registry.unregister(entry.id);
}
}
/** Test helper. */
export function resetSettingsComponents(): void {
registry.reset();
+4 -7
View File
@@ -26,11 +26,8 @@ import {
Play,
Clipboard,
} from "lucide-react";
import {
getRecentActivity,
getSnippets,
type RecentActivityItem,
} from "@/main-axios";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import { listSnippets } from "@/lib/snippet-provider";
import type { Host, TabType, Tab, Snippet } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
@@ -85,8 +82,8 @@ export function CommandPalette({
getRecentActivity(5)
.then(setRecentActivity)
.catch(() => {});
getSnippets()
.then((data) => setSnippets((data ?? []) as unknown as Snippet[]))
listSnippets()
.then((data) => setSnippets(data as unknown as Snippet[]))
.catch(() => {});
}
}, [isOpen]);
-13
View File
@@ -104,10 +104,6 @@ export function registerAction(
};
}
export function unregisterAction(id: string): void {
if (actions.delete(id)) emit();
}
/** Every registered action. */
export function listActions(): RegisteredAction[] {
return [...actions.values()];
@@ -151,15 +147,6 @@ export function declareActionSlot(slot: ActionSlotDefinition): () => void {
};
}
/** Whether a slot is declared, i.e. its owner is running. */
export function isActionSlotDeclared(slotId: string): boolean {
return slots.has(slotId);
}
export function undeclareActionSlot(slotId: string): void {
if (slots.delete(slotId)) emit();
}
/**
* Adds a contribution to a slot.
*
-1
View File
@@ -24,7 +24,6 @@ export interface PanelDef {
const registry = createRegistry<PanelDef>();
export const registerPanel = registry.register;
export const unregisterPanel = registry.unregister;
export const getPanel = registry.get;
export const listPanels = registry.list;
export const usePanels = registry.useList;
+1 -1
View File
@@ -37,7 +37,7 @@ const CORE_UNSAVED = new Set([
* failed, still loading or not installed. The shell shows a placeholder for
* it, and a layout keeps it so nothing is lost when the plugin comes back.
*/
export function isUnregisteredPluginTabType(type: string): boolean {
function isUnregisteredPluginTabType(type: string): boolean {
return (
!CORE_UNSAVED.has(type) && !CORE_CAPTURABLE.has(type) && !getTabType(type)
);
+1 -3
View File
@@ -81,10 +81,8 @@ export interface TabTypeDef {
const registry = createRegistry<TabTypeDef>();
export const registerTabType = registry.register;
export const unregisterTabType = registry.unregister;
export const getTabType = registry.get;
export const listTabTypes = registry.list;
export const subscribeToTabTypes = registry.subscribe;
export const useTabTypes = registry.useList;
export const resetTabTypes = registry.reset;
@@ -115,7 +113,7 @@ export function isSessionTabType(type: string): boolean {
return CORE_SESSION.has(type) || !!registry.get(type)?.session;
}
export function isHostlessTabType(type: string): boolean {
function isHostlessTabType(type: string): boolean {
return CORE_HOSTLESS.has(type) || !!registry.get(type)?.hostless;
}
+1 -1
View File
@@ -768,7 +768,7 @@ export function AdminSettingsPanel({
const total =
(s.sshHostsImported || 0) +
(s.sshCredentialsImported || 0) +
(s.fileManagerItemsImported || 0) +
(s.pluginItemsImported || 0) +
(s.dismissedAlertsImported || 0) +
(s.settingsImported || 0);
toast.success(
+18 -233
View File
@@ -24,10 +24,6 @@ import {
adminDeleteUserHost,
adminGetUserCredentials,
adminDeleteUserCredential,
adminGetUserSnippets,
adminCreateUserSnippet,
adminUpdateUserSnippet,
adminDeleteUserSnippet,
adminResetUserPassword,
adminExportUserData,
getSessions,
@@ -55,6 +51,7 @@ import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
import { useActionSlot } from "@/hooks/use-action-slot";
type ApiErrorLike = {
response?: {
@@ -65,18 +62,9 @@ type ApiErrorLike = {
};
};
// user-snippets rather than the snippets plugin's own id: this is a tab key
// in this admin panel's own local state, not a lookup into the plugin registry.
type ManageTabId =
"account" | "hosts" | "credentials" | "user-snippets" | "sessions" | "danger";
type ManagedSnippet = {
id: number;
name: string;
content: string;
description?: string | null;
folder?: string | null;
};
// Core tabs, or "plugin:<actionId>" for a tab a plugin contributes to the
// admin.userTabs slot.
type ManageTabId = string;
type EditorState =
| { kind: "host"; host: Host | null }
@@ -91,19 +79,6 @@ function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
function mapSnippets(res: unknown): ManagedSnippet[] {
const list = Array.isArray(res)
? res
: ((res as { snippets?: unknown[] })?.snippets ?? []);
return (list as Record<string, unknown>[]).map((s) => ({
id: Number(s.id),
name: String(s.name ?? ""),
content: String(s.content ?? ""),
description: (s.description as string | null) ?? null,
folder: (s.folder as string | null) ?? null,
}));
}
export function AdminUserManagePanel({
user,
roles,
@@ -150,23 +125,11 @@ export function AdminUserManagePanel({
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [snippets, setSnippets] = useState<ManagedSnippet[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [loading, setLoading] = useState(true);
// Snippet inline editor
const [editingSnippet, setEditingSnippet] = useState<
ManagedSnippet | "new" | null
>(null);
const [snippetForm, setSnippetForm] = useState({
name: "",
content: "",
folder: "",
});
const [snippetSaving, setSnippetSaving] = useState(false);
// Account tab state
const [resetPassword, setResetPassword] = useState("");
const [resetLoading, setResetLoading] = useState(false);
@@ -194,13 +157,6 @@ export function AdminUserManagePanel({
.catch(() => {});
};
const reloadSnippets = () => {
if (!dataUnlocked) return;
adminGetUserSnippets(user.id)
.then((res) => setSnippets(mapSnippets(res)))
.catch(() => {});
};
const reloadSessions = () => {
getSessions()
.then(({ sessions: s }) =>
@@ -214,12 +170,11 @@ export function AdminUserManagePanel({
Promise.allSettled([
dataUnlocked ? adminGetUserHosts(user.id) : Promise.resolve([]),
dataUnlocked ? adminGetUserCredentials(user.id) : Promise.resolve([]),
dataUnlocked ? adminGetUserSnippets(user.id) : Promise.resolve([]),
getSessions(),
getApiKeys(),
getUserRoles(user.id),
])
.then(([h, c, s, sess, keys, ur]) => {
.then(([h, c, sess, keys, ur]) => {
if (h.status === "fulfilled" && dataUnlocked) {
setHosts(
(h.value as SSHHostWithStatus[]).map((raw) => sshHostToHost(raw)),
@@ -228,9 +183,6 @@ export function AdminUserManagePanel({
if (c.status === "fulfilled" && dataUnlocked) {
setCredentials(mapCredentials(c.value));
}
if (s.status === "fulfilled" && dataUnlocked) {
setSnippets(mapSnippets(s.value));
}
if (sess.status === "fulfilled") {
setSessions(
sess.value.sessions.filter((session) => session.userId === user.id),
@@ -246,11 +198,21 @@ export function AdminUserManagePanel({
.finally(() => setLoading(false));
}, [user.id, dataUnlocked]);
const pluginTabs = useActionSlot("admin.userTabs").filter(
(contribution) =>
contribution.kind === "component" && contribution.component,
);
const activePluginTab = pluginTabs.find(
(contribution) => `plugin:${contribution.actionId}` === activeTab,
);
const manageTabs: { id: ManageTabId; label: string }[] = [
{ id: "account", label: t("admin.manageTabAccount") },
{ id: "hosts", label: t("admin.manageTabHosts") },
{ id: "credentials", label: t("admin.manageTabCredentials") },
{ id: "user-snippets", label: t("admin.manageTabSnippets") },
...pluginTabs.map((contribution) => ({
id: `plugin:${contribution.actionId}`,
label: t(contribution.titleKey),
})),
{ id: "sessions", label: t("admin.manageTabSessions") },
{ id: "danger", label: t("admin.manageTabDanger") },
];
@@ -348,33 +310,6 @@ export function AdminUserManagePanel({
}
}
async function handleSaveSnippet() {
if (!snippetForm.name.trim() || !snippetForm.content.trim()) {
toast.error(t("admin.snippetRequiredFields"));
return;
}
setSnippetSaving(true);
try {
const payload = {
name: snippetForm.name.trim(),
content: snippetForm.content,
folder: snippetForm.folder.trim() || null,
};
if (editingSnippet !== "new" && editingSnippet) {
await adminUpdateUserSnippet(user.id, editingSnippet.id, payload);
} else {
await adminCreateUserSnippet(user.id, payload);
}
setEditingSnippet(null);
reloadSnippets();
toast.success(t("admin.snippetSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.snippetSaveFailed")));
} finally {
setSnippetSaving(false);
}
}
const lockedNotice = (
<div className="flex items-start gap-2.5 p-3 mt-2 border border-border bg-muted/20 text-xs text-muted-foreground">
<Lock className="size-3.5 shrink-0 mt-0.5" />
@@ -939,161 +874,11 @@ export function AdminUserManagePanel({
</div>
))}
{activeTab === "user-snippets" &&
{activePluginTab &&
(!dataUnlocked ? (
lockedNotice
) : (
<div className="flex flex-col">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.snippetsCount", { count: snippets.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={reloadSnippets}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setSnippetForm({ name: "", content: "", folder: "" });
setEditingSnippet("new");
}}
>
<Plus className="size-3" />
{t("admin.addSnippetForUser")}
</Button>
</div>
</div>
{editingSnippet && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<Input
className="h-8 text-xs"
placeholder={t("admin.snippetNamePlaceholder")}
value={snippetForm.name}
onChange={(e) =>
setSnippetForm((p) => ({ ...p, name: e.target.value }))
}
/>
<textarea
rows={4}
placeholder={t("admin.snippetContentPlaceholder")}
value={snippetForm.content}
onChange={(e) =>
setSnippetForm((p) => ({
...p,
content: e.target.value,
}))
}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
<Input
className="h-8 text-xs"
placeholder={t("admin.snippetFolderPlaceholder")}
value={snippetForm.folder}
onChange={(e) =>
setSnippetForm((p) => ({
...p,
folder: e.target.value,
}))
}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setEditingSnippet(null)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={snippetSaving}
onClick={handleSaveSnippet}
>
{snippetSaving ? t("hosts.savingBtn") : t("common.save")}
</Button>
</div>
</div>
)}
{!loading && snippets.length === 0 && !editingSnippet && (
<span className="text-xs text-muted-foreground py-3">
{t("admin.noSnippetsForUser")}
</span>
)}
{snippets.map((snippet) => (
<div
key={snippet.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[180px]">
{snippet.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate max-w-[220px]">
{snippet.content}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setSnippetForm({
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? "",
});
setEditingSnippet(snippet);
}}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setConfirmDialog({
message: t("admin.deleteSnippetConfirm", {
name: snippet.name,
username: user.username,
}),
onConfirm: async () => {
try {
await adminDeleteUserSnippet(user.id, snippet.id);
setSnippets((prev) =>
prev.filter((s) => s.id !== snippet.id),
);
toast.success(t("admin.snippetDeletedSuccess"));
} catch (e) {
toast.error(
apiErrorMessage(
e,
t("admin.snippetDeleteFailed"),
),
);
}
},
})
}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
<activePluginTab.component user={user} />
))}
{activeTab === "sessions" && (
+2 -2
View File
@@ -294,13 +294,13 @@ export function CredentialEditorView({
onChange={(e) => setCredField("password", e.target.value)}
/>
<PluginComponent
id="secret-sources.hint"
id="credentials.secretHint"
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
{showSecretSources && (
<PluginComponent
id="secret-sources.manager"
id="credentials.secretManager"
onClose={() => setShowSecretSources(false)}
/>
)}
+6 -8
View File
@@ -34,18 +34,17 @@ import {
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { listSnippets } from "@/lib/snippet-provider";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { TerminalPreview } from "@/components/terminal-preview/TerminalPreview";
import {
createSSHHost,
updateSSHHost,
getSnippets,
getUserInfo,
getHostPassword,
adminCreateUserHost,
adminUpdateUserHost,
adminGetHostPassword,
adminGetUserSnippets,
createCredential,
adminCreateUserCredential,
} from "@/main-axios";
@@ -240,10 +239,9 @@ export function HostEditor({
}, []);
useEffect(() => {
const loadSnippets = adminTargetUserId
? adminGetUserSnippets(adminTargetUserId)
: getSnippets();
loadSnippets
listSnippets(
adminTargetUserId ? { targetUserId: adminTargetUserId } : undefined,
)
.then((res) => setSnippets(mapSnippetResponse(res)))
.catch(() => {});
}, [adminTargetUserId]);
@@ -690,7 +688,7 @@ export function HostEditor({
onChange={(e) => setField("password", e.target.value)}
/>
<PluginComponent
id="secret-sources.hint"
id="credentials.secretHint"
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
@@ -698,7 +696,7 @@ export function HostEditor({
{(authMethod === "password" || authMethod === "key") &&
showSecretSources && (
<PluginComponent
id="secret-sources.manager"
id="credentials.secretManager"
onClose={() => setShowSecretSources(false)}
/>
)}
+1 -7
View File
@@ -21,7 +21,6 @@ import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
HOMEPAGE_AREA_KEY,
UI_AREA_KEYS,
type UiAreaKey,
type UiPreset,
@@ -39,9 +38,6 @@ const PRESET_ICONS = {
advanced: Wrench,
} as const;
/** Areas that have nothing worth listing as a per-area override. */
const HIDDEN_OVERRIDE_AREAS = new Set<UiAreaKey>([HOMEPAGE_AREA_KEY]);
export function InterfacePresetSettings({
onRunSetupAgain,
}: {
@@ -67,9 +63,7 @@ export function InterfacePresetSettings({
Record<string, unknown> | undefined
>;
const core = UI_AREA_KEYS.filter(
(area) =>
!HIDDEN_OVERRIDE_AREAS.has(area) &&
Object.keys(overrides[area] ?? {}).length > 0,
(area) => Object.keys(overrides[area] ?? {}).length > 0,
).map((area) => ({
key: area as string,
label: t(`newUi.sidebar.userProfile.uiArea_${area}`),
+3 -2
View File
@@ -5,7 +5,8 @@ import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences, getSnippets } from "@/main-axios";
import { saveUserPreferences } from "@/main-axios";
import { listSnippets } from "@/lib/snippet-provider";
import { BUILT_IN_DEFAULTS } from "@/lib/default-keybindings";
import type {
CustomKeybinding,
@@ -87,7 +88,7 @@ export function KeybindingsDialog({
setLoading(true);
Promise.all([
getUserPreferences().catch(() => null),
getSnippets().catch(() => []),
listSnippets().catch(() => []),
])
.then(([prefs, snippetList]) => {
setBindings(
+55 -18
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
import { Eye, EyeOff, Terminal } from "lucide-react";
import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
@@ -9,6 +9,12 @@ import { createQuickConnectHost } from "./quick-connect-host";
import { useHostProtocols } from "./host-protocols";
import { Select2 } from "@/components/select2";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useHostActions } from "./host-contributions";
// Core types Quick Connect draws its own fields for.
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
interface QuickConnectPanelProps {
onConnect: (host: Host, type: string) => void;
@@ -22,9 +28,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const [port, setPort] = useState("22");
const [domain, setDomain] = useState("");
const [username, setUsername] = useState("root");
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
"password",
);
const [authType, setAuthType] = useState("password");
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -39,6 +44,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
.catch(() => {});
}, []);
// Every registered SSH auth type that works for a host that is never saved.
const { providers } = useSshAuthProviders();
const authOptions = providers.filter(
(option) => option.available && option.quickConnect,
);
const authEditor = useSshAuthEditors().find(
(editor) => editor.id === authType,
);
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
? undefined
: authEditor?.component;
const quickActions = useHostActions().filter(
(action) => action.quickConnect && action.tabType,
);
const pluginProtocols = useHostProtocols().filter(
(entry) => entry.quickConnect,
);
@@ -67,6 +87,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
credentialId,
protocol: selected,
domain: domain || undefined,
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
});
const target = type ?? resolveHostTabType(hostConfig);
if (target) onConnect(hostConfig, target);
@@ -152,18 +173,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.authLabel")}
</label>
<div className="flex gap-1">
{(["password", "key", "credential"] as const).map((type) => (
<div className="flex flex-wrap gap-1">
{authOptions.map((option) => (
<button
key={type}
onClick={() => setAuthType(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors capitalize ${
authType === type
key={option.type}
onClick={() => {
setAuthType(option.type);
setAuthFields({});
}}
className={`flex-1 py-1 px-1.5 text-[10px] font-semibold border transition-colors ${
authType === option.type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
{t(option.editorTitleKey ?? option.labelKey)}
</button>
))}
</div>
@@ -257,6 +281,14 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</Select2>
</div>
)}
{!isDesktop && AuthEditor && (
<AuthEditor
form={authFields}
setField={(key, value) =>
setAuthFields((current) => ({ ...current, [key]: value }))
}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
{selected ? (
<button
@@ -277,13 +309,18 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
</button>
<button
onClick={() => connect("files")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<FolderSearch className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToFiles")}
</button>
{quickActions.map((action) => (
<button
key={action.id}
onClick={() => connect(action.tabType)}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<action.icon className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToAction", {
name: t(action.titleKey),
})}
</button>
))}
</>
)}
</div>
+1 -1
View File
@@ -1256,7 +1256,7 @@ export function UserProfilePanel({
const total =
(s.sshHostsImported || 0) +
(s.sshCredentialsImported || 0) +
(s.fileManagerItemsImported || 0) +
(s.pluginItemsImported || 0) +
(s.dismissedAlertsImported || 0) +
(s.settingsImported || 0);
toast.success(
+1 -1
View File
@@ -1,6 +1,6 @@
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
export const BRANDING_LOGO_ACCEPT = "image/png,image/jpeg,image/webp";
export const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
@@ -19,14 +19,6 @@ import type {
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
cred.tags?.some((t) => t.toLowerCase().includes(query))
);
}
/**
* Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in
* HostItem.tsx -- one implementation with a token lookup instead of two
+1 -1
View File
@@ -1,2 +1,2 @@
export { CredentialSidebarTree } from "./CredentialSidebarTree";
export { isFolder, type CredentialFolder } from "./visible-rows";
export { type CredentialFolder } from "./visible-rows";
@@ -17,7 +17,7 @@ export function isFolder(
return "children" in item;
}
export function credentialMatchesQuery(cred: Credential, query: string) {
function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
+2
View File
@@ -24,6 +24,8 @@ export interface HostActionDef {
copyUrlView?: string;
/** Where the dashboard's host status list sends a click. */
overview?: boolean;
/** Offered as a button in Quick Connect, opening tabType. */
quickConnect?: boolean;
order?: number;
/** A label worked out per host, e.g. a single endpoint's name. */
label?: (host: Host) => string | undefined;
+1 -1
View File
@@ -15,7 +15,7 @@ export interface ExportPayload {
hosts: Record<string, unknown>[];
}
export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
connection: [
"connectionType",
"name",
+4 -1
View File
@@ -9,9 +9,11 @@ type QuickConnectInput = Pick<
/** A plugin protocol; SSH when omitted. */
protocol?: HostProtocolDef;
domain?: string;
/** Fields a plugin's SSH auth editor filled in. */
authFields?: Record<string, unknown>;
};
export const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
@@ -60,6 +62,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
quickActions: [],
enableSsh: true,
sshPort: input.port,
...input.authFields,
};
}
+2 -6
View File
@@ -53,6 +53,8 @@ export interface RailItemDef {
electronOnly?: boolean;
/** False keeps it out of the Navigation visibility toggles. */
hideable?: boolean;
/** A plugin item that stays visible in the Simple preset. */
simplePreset?: boolean;
/** Registered but not shown, e.g. while its feature is switched off. */
hidden?: boolean;
/** Places a registered item after this id instead of at the end. */
@@ -123,12 +125,6 @@ export function registerRailItem(def: RailItemDef): () => void {
return registeredRailItems.register(def);
}
export function unregisterRailItem(id: string): void {
registeredRailItems.unregister(id);
}
export const subscribeToRailItems = registeredRailItems.subscribe;
/** Registered items, hidden ones included. */
export const listRegisteredRailItems = registeredRailItems.list;
+3 -3
View File
@@ -117,11 +117,11 @@ function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
export async function writeClipboardText(value: string): Promise<void> {
async function writeClipboardText(value: string): Promise<void> {
await copyToClipboard(value);
}
export function canCopyHostPassword(host: Host): boolean {
function canCopyHostPassword(host: Host): boolean {
return (
host.authType === "password" ||
host.authType === "credential" ||
@@ -130,7 +130,7 @@ export function canCopyHostPassword(host: Host): boolean {
);
}
export function canCopyHostSudoPassword(host: Host): boolean {
function canCopyHostSudoPassword(host: Host): boolean {
return (
!!host.hasSudoPassword ||
!!host.sudoPassword ||
+1 -2
View File
@@ -1,4 +1,3 @@
export { isFolder } from "./visible-rows";
export { HostItem } from "./HostItem/HostItem";
export { FolderItem } from "./FolderItem/FolderItem";
export { SidebarTree } from "./SidebarTree";
+23 -1
View File
@@ -11,7 +11,8 @@ vi.mock("@/dashboard/dashboard-cards-registry", () => ({
getRegisteredDashboardCard: (id: string) => registered.cards.get(id),
}));
const { buildDashboardSlots } = await import("../../lib/apply-ui-preset.js");
const { buildDashboardSlots, presetHiddenRailTabs } =
await import("../../lib/apply-ui-preset.js");
afterEach(() => {
registered.cards.clear();
@@ -69,3 +70,24 @@ describe("buildDashboardSlots", () => {
]);
});
});
describe("presetHiddenRailTabs", () => {
const items = [
{ id: "snippets", simplePreset: true },
{ id: "docker-like" },
{ id: "pinned", hideable: false },
];
it("hides plugin items that do not opt in when the preset asks", () => {
expect(
presetHiddenRailTabs(
{ hiddenTabs: ["macros"], hidePluginItems: true },
items,
),
).toEqual(["macros", "docker-like"]);
});
it("leaves plugin items alone otherwise", () => {
expect(presetHiddenRailTabs({ hiddenTabs: [] }, items)).toEqual([]);
});
});
@@ -1,84 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createKeyedRequestCache } from "../../lib/keyed-request-cache";
afterEach(() => vi.useRealTimers());
describe("createKeyedRequestCache", () => {
it("coalesces requests per key without mixing keys", async () => {
let resolve!: (value: string) => void;
const loader = vi.fn(() => new Promise<string>((done) => (resolve = done)));
const cache = createKeyedRequestCache<string>(5_000);
const first = cache.get("host:1", loader);
const duplicate = cache.get("host:1", loader);
const other = cache.get("host:2", async () => "other");
resolve("shared");
await expect(Promise.all([first, duplicate, other])).resolves.toEqual([
"shared",
"shared",
"other",
]);
expect(loader).toHaveBeenCalledOnce();
});
it("can show stale data immediately while forcing one refresh", async () => {
vi.useFakeTimers();
const cache = createKeyedRequestCache<string>(1_000);
await cache.get("directory", async () => "old");
await vi.advanceTimersByTimeAsync(2_000);
expect(cache.peek("directory", 30_000)).toBe("old");
const loader = vi.fn(async () => "fresh");
const [a, b] = await Promise.all([
cache.get("directory", loader, { force: true }),
cache.get("directory", loader, { force: true }),
]);
expect([a, b]).toEqual(["fresh", "fresh"]);
expect(loader).toHaveBeenCalledOnce();
});
it("evicts the least recently used entry when bounded", async () => {
const cache = createKeyedRequestCache<number>(10_000, 2);
await cache.get("a", async () => 1);
await cache.get("b", async () => 2);
expect(cache.peek("a")).toBe(1);
await cache.get("c", async () => 3);
expect(cache.peek("a")).toBe(1);
expect(cache.peek("b")).toBeNull();
expect(cache.peek("c")).toBe(3);
});
it("keeps stale data when a refresh fails", async () => {
const cache = createKeyedRequestCache<string>(1_000);
await cache.get("key", async () => "safe");
await expect(
cache.get(
"key",
async () => {
throw new Error("offline");
},
{ force: true },
),
).rejects.toThrow("offline");
expect(cache.peek("key")).toBe("safe");
});
it("does not let an invalidated request overwrite newer data", async () => {
let resolveOld!: (value: string) => void;
const cache = createKeyedRequestCache<string>(10_000);
const oldRequest = cache.get(
"key",
() => new Promise<string>((resolve) => (resolveOld = resolve)),
);
cache.set("key", "optimistic");
resolveOld("old");
await oldRequest;
expect(cache.peek("key")).toBe("optimistic");
});
});
@@ -10,10 +10,6 @@ const api = vi.hoisted(() => ({
adminDeleteUserHost: vi.fn(async () => ({})),
adminGetUserCredentials: vi.fn(async () => [] as unknown[]),
adminDeleteUserCredential: vi.fn(async () => ({})),
adminGetUserSnippets: vi.fn(async () => [] as unknown[]),
adminCreateUserSnippet: vi.fn(async () => ({})),
adminUpdateUserSnippet: vi.fn(async () => ({})),
adminDeleteUserSnippet: vi.fn(async () => ({})),
adminResetUserPassword: vi.fn(async () => ({ dataWiped: false })),
adminExportUserData: vi.fn(async () => ({})),
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
@@ -87,7 +83,6 @@ describe("AdminUserManagePanel", () => {
"admin.manageTabAccount",
"admin.manageTabHosts",
"admin.manageTabCredentials",
"admin.manageTabSnippets",
"admin.manageTabSessions",
"admin.manageTabDanger",
]) {
@@ -97,7 +92,6 @@ describe("AdminUserManagePanel", () => {
await waitFor(() => {
expect(api.adminGetUserHosts).toHaveBeenCalledWith("u2");
expect(api.adminGetUserCredentials).toHaveBeenCalledWith("u2");
expect(api.adminGetUserSnippets).toHaveBeenCalledWith("u2");
expect(api.getUserRoles).toHaveBeenCalledWith("u2");
});
});
@@ -113,7 +107,6 @@ describe("AdminUserManagePanel", () => {
expect(api.adminGetUserHosts).not.toHaveBeenCalled();
expect(api.adminGetUserCredentials).not.toHaveBeenCalled();
expect(api.adminGetUserSnippets).not.toHaveBeenCalled();
});
it("lists the user's hosts and opens a tab via the connect button", async () => {
@@ -142,28 +135,43 @@ describe("AdminUserManagePanel", () => {
});
});
it("creates a snippet for the target user", async () => {
renderPanel(makeUser());
await userEvent.click(screen.getByText("admin.manageTabSnippets"));
await userEvent.click(screen.getByText("admin.addSnippetForUser"));
await userEvent.type(
screen.getByPlaceholderText("admin.snippetNamePlaceholder"),
"restart",
);
await userEvent.type(
screen.getByPlaceholderText("admin.snippetContentPlaceholder"),
"systemctl restart nginx",
);
await userEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(api.adminCreateUserSnippet).toHaveBeenCalledWith("u2", {
name: "restart",
content: "systemctl restart nginx",
folder: null,
});
it("shows a tab a plugin contributes, with the user it manages", async () => {
const { registerSlotContribution } =
await import("../../shell/action-registry");
const dispose = registerSlotContribution("admin.userTabs", {
actionId: "fixture.adminTab",
titleKey: "fixture.tabTitle",
kind: "component",
component: ((props: { user: AdminUser }) => (
<div>managing {props.user.username}</div>
)) as never,
});
try {
renderPanel(makeUser());
await userEvent.click(screen.getByText("fixture.tabTitle"));
expect(screen.getByText("managing bob")).toBeTruthy();
} finally {
dispose();
}
});
it("shows the locked notice instead of a plugin tab while data is locked", async () => {
const { registerSlotContribution } =
await import("../../shell/action-registry");
const dispose = registerSlotContribution("admin.userTabs", {
actionId: "fixture.adminTab",
titleKey: "fixture.tabTitle",
kind: "component",
component: (() => <div>plugin content</div>) as never,
});
try {
renderPanel(makeUser({ dataUnlocked: false }));
await userEvent.click(screen.getByText("fixture.tabTitle"));
expect(screen.queryByText("plugin content")).toBeNull();
expect(screen.getByText("admin.dataLockedNotice")).toBeTruthy();
} finally {
dispose();
}
});
it("deletes the user from the danger tab after confirmation", async () => {
@@ -97,4 +97,19 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
"demo-desktopPassword": "pw",
});
});
it("carries the fields a plugin's SSH auth editor filled in", () => {
const host = createQuickConnectHost({
ip: "100.64.0.9",
port: 22,
username: "root",
authType: "tailnet-login",
authFields: { pluginSettings: { demo: { profileId: 3 } } },
});
expect(host.authType).toBe("tailnet-login");
expect(host.password).toBeUndefined();
expect(host).toMatchObject({
pluginSettings: { demo: { profileId: 3 } },
});
});
});
+11 -12
View File
@@ -94,20 +94,18 @@ describe("sanitizeUiOverrides", () => {
).toBe(false);
});
it("drops the retired homepage area", () => {
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
{},
);
});
it("filters non-strings out of string arrays instead of rejecting them", () => {
const result = sanitizeUiOverrides({
rail: { hiddenTabs: ["serial", 7, null, "history"] },
});
expect(result.rail?.hiddenTabs).toEqual(["serial", "history"]);
});
it("keeps an explicit null for nullable array knobs", () => {
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
{
homepage: { enabledWidgets: null },
},
);
});
});
describe("resolveArea", () => {
@@ -155,12 +153,13 @@ describe("PRESETS.balanced", () => {
expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled");
expect(PRESETS.balanced.fileManager.viewMode).toBe("grid");
expect(PRESETS.balanced.hostEditor.mode).toBe("full");
expect(PRESETS.balanced.homepage.enabledWidgets).toBeNull();
});
it("never lets a preset drive the homepage canvas", () => {
expect(PRESETS.simple.homepage.enabledWidgets).toBeNull();
expect(PRESETS.advanced.homepage.enabledWidgets).toBeNull();
it("keeps core's basics in Simple and hides plugin rail items that do not opt in", () => {
expect(PRESETS.simple.rail.hiddenTabs).not.toContain("hosts");
expect(PRESETS.simple.rail.hiddenTabs).not.toContain("connections");
expect(PRESETS.simple.rail.hidePluginItems).toBe(true);
expect(PRESETS.balanced.rail.hidePluginItems).toBeFalsy();
});
it("leaves the wide dashboard cards off by default in every preset", () => {