From 835b98a102a44cdb20ee14889fcc04c206a2d3fe Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 12 May 2026 02:12:02 -0500 Subject: [PATCH] feat: continued migration --- package.json | 2 +- src/backend/dashboard.ts | 8 +- src/types/ui-types.ts | 4 +- src/ui/dashboard/Dashboard.tsx | 84 +- src/ui/dashboard/DashboardTab.tsx | 300 ++-- src/ui/dashboard/cards/NetworkGraphCard.tsx | 47 +- src/ui/dashboard/cards/RecentActivityCard.tsx | 24 +- src/ui/dashboard/cards/ServerOverviewCard.tsx | 18 +- src/ui/dashboard/cards/ServerStatsCard.tsx | 30 +- .../hooks/useDashboardPreferences.ts | 77 +- src/ui/features/file-manager/FileManager.tsx | 174 +- .../file-manager/FileManagerContextMenu.tsx | 29 +- .../file-manager/components/PdfPreview.tsx | 3 +- .../components/TerminalWindow.tsx | 49 +- src/ui/locales/en.json | 27 +- src/ui/shell/TabBar.tsx | 2 +- src/ui/sidebar/HostManager.tsx | 1532 +++++++++++++---- src/ui/sidebar/SidebarTree.tsx | 106 +- src/ui/ssh/connection-log/ConnectionLog.tsx | 2 +- 19 files changed, 1836 insertions(+), 682 deletions(-) diff --git a/package.json b/package.json index 17e1f3e4..ace8a8dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.2.1", + "version": "2.3.0", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index f8963b7c..77314262 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -470,7 +470,7 @@ app.post("/dashboard/preferences", async (req, res) => { }); } - const { cards } = req.body; + const { cards, mainWidthPct } = req.body; if (!cards || !Array.isArray(cards)) { return res.status(400).json({ @@ -478,7 +478,11 @@ app.post("/dashboard/preferences", async (req, res) => { }); } - const layout = JSON.stringify({ cards }); + const layoutObj: Record = { cards }; + if (typeof mainWidthPct === "number") { + layoutObj.mainWidthPct = mainWidthPct; + } + const layout = JSON.stringify(layoutObj); const existing = await getDb() .select() diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index f8d3cad5..087366fe 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -6,8 +6,8 @@ export type Host = { port: number; folder: string; online: boolean; - cpu: number; - ram: number; + cpu: number | null; + ram: number | null; lastAccess: string; tags?: string[]; authType: "password" | "key" | "credential" | "none" | "opkssh"; diff --git a/src/ui/dashboard/Dashboard.tsx b/src/ui/dashboard/Dashboard.tsx index ebfec4f9..bf3d6ec8 100644 --- a/src/ui/dashboard/Dashboard.tsx +++ b/src/ui/dashboard/Dashboard.tsx @@ -129,6 +129,31 @@ export function Dashboard({ [mainWidthPct, layout, updateLayout], ); + const handleCardHeightMouseDown = useCallback( + (e: React.MouseEvent, cardId: string, currentHeight: number) => { + e.preventDefault(); + const startY = e.clientY; + const startH = currentHeight; + const onMove = (ev: MouseEvent) => { + const newH = Math.max(180, startH + (ev.clientY - startY)); + if (!layout) return; + updateLayout({ + ...layout, + cards: layout.cards.map((c) => + c.id === cardId ? { ...c, height: Math.round(newH) } : c, + ), + }); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [layout, updateLayout], + ); + let sidebarState: "expanded" | "collapsed" = "expanded"; try { const sidebar = useSidebar(); @@ -385,7 +410,7 @@ export function Dashboard({ name: string; cpu: number | null; ram: number | null; - } => server !== null && server.cpu !== null && server.ram !== null, + } => server !== null, ); setServerStats(validServerStats); setServerStatsLoading(false); @@ -788,24 +813,40 @@ export function Dashboard({ return null; }; + const renderCardWithHandle = ( + card: (typeof enabledCards)[0], + ) => { + const currentHeight = card.height ?? 280; + return ( +
+
{renderCard(card)}
+
+ handleCardHeightMouseDown(e, card.id, currentHeight) + } + > +
+
+
+ ); + }; + return ( <>
- {mainCards.map((card) => ( -
- {renderCard(card)} -
- ))} + {mainCards.map((card) => renderCardWithHandle(card))}
- {sideCards.map((card) => ( -
- {renderCard(card)} -
- ))} + {sideCards.map((card) => renderCardWithHandle(card))}
); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index ea3c8f7b..6fa84b88 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -27,12 +27,15 @@ import type { DashboardCardId, TabType, Host } from "@/types/ui-types"; import { getSSHHosts, getUptime, + getVersionInfo, + getDatabaseHealth, getRecentActivity, getTunnelStatuses, getCredentials, resetRecentActivity, } from "@/main-axios"; import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios"; +import { useTranslation } from "react-i18next"; function sshHostToHost(h: SSHHostWithStatus): Host { return { @@ -110,15 +113,6 @@ const DEFAULT_SLOTS: CardSlot[] = [ { id: "recent_activity", panel: "side", order: 0, height: null }, ]; -const CARD_META: Record = { - stats_bar: { label: "Status Bar" }, - counters_bar: { label: "Counters" }, - quick_actions: { label: "Quick Actions" }, - host_status: { label: "Host Status" }, - recent_activity: { label: "Recent Activity" }, - network_graph: { label: "Network Graph" }, -}; - // ─── useColumnResize ────────────────────────────────────────────────────────── function useColumnResize( @@ -166,27 +160,48 @@ function useColumnResize( function StatsBarCard({ hosts, uptimeFormatted, + versionText, + versionStatus, + dbHealth, }: { hosts: Host[]; uptimeFormatted: string; + versionText: string; + versionStatus: "up_to_date" | "requires_update" | "beta"; + dbHealth: "healthy" | "error"; }) { + const { t } = useTranslation(); const online = hosts.filter((h) => h.online).length; + const statusLabel = + versionStatus === "beta" + ? t("dashboard.beta").toUpperCase() + : versionStatus === "requires_update" + ? t("dashboard.updateAvailable").toUpperCase() + : t("dashboardTab.stable"); + const statusColor = + versionStatus === "beta" + ? "bg-blue-500/20 text-blue-400" + : versionStatus === "requires_update" + ? "bg-yellow-500/20 text-yellow-400" + : "bg-accent-brand/20 text-accent-brand"; return (
- Version + {t("dashboard.version")} - v2.2.0 + {versionText || "—"} - - STABLE + + {statusLabel}
- Uptime + {t("dashboard.uptime")} {uptimeFormatted || "—"} @@ -194,15 +209,19 @@ function StatsBarCard({
- Database + {t("dashboard.database")} - - Healthy + + {dbHealth === "healthy" + ? t("dashboard.healthy") + : t("dashboard.error")}
- Hosts Online + {t("dashboardTab.hostsOnline")}
{online} @@ -224,27 +243,28 @@ function CountersBarCard({ credentialCount: number; activeTunnelCount: number; }) { + const { t } = useTranslation(); return (
{hosts.length} - Total Hosts + {t("dashboard.totalHosts")}
{credentialCount} - Credentials + {t("dashboard.totalCredentials")}
{activeTunnelCount} - Active Tunnels + {t("dashboardTab.activeTunnels")}
@@ -256,12 +276,13 @@ function QuickActionsCard({ }: { onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; }) { + const { t } = useTranslation(); return (
- Quick Actions + {t("dashboard.quickActions")}
@@ -276,9 +297,11 @@ function QuickActionsCard({
- Add Host + + {t("dashboard.addHost")} + - Register a new server + {t("dashboardTab.registerNewServer")}
@@ -292,9 +315,11 @@ function QuickActionsCard({
- Add Credential + + {t("dashboard.addCredential")} + - Store SSH keys or passwords + {t("dashboardTab.storeSshKeysOrPasswords")}
@@ -308,9 +333,11 @@ function QuickActionsCard({
- Admin Settings + + {t("dashboard.adminSettings")} + - Manage users and roles + {t("dashboardTab.manageUsersAndRoles")}
@@ -322,9 +349,11 @@ function QuickActionsCard({
- User Profile + + {t("dashboard.userProfile")} + - Manage your account + {t("dashboardTab.manageYourAccount")}
@@ -341,6 +370,7 @@ function HostStatusCard({ hosts: Host[]; onOpenTab: (host: Host, type: TabType) => void; }) { + const { t } = useTranslation(); const online = hosts.filter((h) => h.online).length; return ( @@ -348,17 +378,17 @@ function HostStatusCard({
- Host Status + {t("dashboardTab.hostStatus")}
- {online}/{hosts.length} online + {online}/{hosts.length} {t("dashboardTab.onlineLower")}
{hosts.length === 0 && (
- No hosts configured + {t("dashboardTab.noHostsConfigured")}
)} {hosts.map((host, i) => ( @@ -384,7 +414,7 @@ function HostStatusCard({
- CPU + {t("dashboard.cpu")} {host.cpu ?? 0}% @@ -400,7 +430,7 @@ function HostStatusCard({
- RAM + {t("dashboard.ram")} {host.ram ?? 0}% @@ -427,7 +457,9 @@ function HostStatusCard({ - {host.online ? "ONLINE" : "OFFLINE"} + {host.online + ? t("dashboardTab.online") + : t("dashboardTab.offline")}
@@ -448,6 +480,7 @@ function RecentActivityCard({ onOpenTab: (host: Host, type: TabType) => void; onClear: () => void; }) { + const { t } = useTranslation(); const typeIcon: Record = { terminal: , file_manager: , @@ -468,12 +501,24 @@ function RecentActivityCard({ vnc: "vnc", telnet: "telnet", }; + const typeLabel: Record = { + terminal: t("networkGraph.terminal"), + file_manager: t("networkGraph.fileManager"), + server_stats: t("networkGraph.serverStats"), + tunnel: t("networkGraph.tunnel"), + docker: t("networkGraph.docker"), + rdp: "RDP", + vnc: "VNC", + telnet: "Telnet", + }; function formatTime(ts: string) { - const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; + const diffMs = Date.now() - new Date(ts).getTime(); + if (diffMs < 0) return t("dashboard.justNow"); + const diff = Math.floor(diffMs / 1000); + if (diff < 60) return t("dashboard.justNow"); + if (diff < 3600) return `${Math.floor(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h`; + return `${Math.floor(diff / 86400)}d`; } return ( @@ -481,7 +526,7 @@ function RecentActivityCard({
- Recent Activity + {t("dashboard.recentActivity")}
{activity.length === 0 && (
- No recent activity + {t("dashboard.noRecentActivity")}
)} {activity.map((item) => { @@ -519,9 +564,7 @@ function RecentActivityCard({
{typeIcon[item.type]} - - {item.type.replace("_", " ")} - + {typeLabel[item.type]}
@@ -537,6 +580,7 @@ function RecentActivityCard({ } function NetworkGraphCard({ hosts }: { hosts: Host[] }) { + const { t } = useTranslation(); const cyRef = useRef(null); const [contextMenu, setContextMenu] = useState<{ visible: boolean; @@ -639,12 +683,12 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
- Network Graph + {t("dashboard.networkGraph")}
- {hosts.length} nodes + {t("dashboardTab.nodes", { count: hosts.length })}
)} @@ -695,20 +739,20 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) { /> ) : (
- No hosts to display + {t("dashboardTab.noHostsToDisplay")}
)}
- Online + {t("dashboardTab.onlineLower")}
- Offline + {t("dashboardTab.offlineLower")}
@@ -732,6 +776,9 @@ function CardItem({ onOpenTab, hosts, uptimeFormatted, + versionText, + versionStatus, + dbHealth, credentialCount, activeTunnelCount, activity, @@ -749,6 +796,9 @@ function CardItem({ onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; uptimeFormatted: string; + versionText: string; + versionStatus: "up_to_date" | "requires_update" | "beta"; + dbHealth: "healthy" | "error"; credentialCount: number; activeTunnelCount: number; activity: RecentActivityItem[]; @@ -776,15 +826,6 @@ function CardItem({ ); const isFlex = slot.height === null; - const cardProps = { - hosts, - uptimeFormatted, - credentialCount, - activeTunnelCount, - activity, - onOpenTab, - onOpenSingletonTab, - }; return (
{slot.id === "stats_bar" && ( )} {slot.id === "counters_bar" && ( )} {slot.id === "quick_actions" && ( - + )} {slot.id === "host_status" && ( - + )} {slot.id === "recent_activity" && ( )} - {slot.id === "network_graph" && ( - - )} + {slot.id === "network_graph" && }
{editMode && !isFlex && (
void; + cardLabels: Record; }) { + const { t } = useTranslation(); const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id)); if (available.length === 0) return null; return (
- Add: + {t("dashboardTab.add")} {available.map((card) => ( ))}
@@ -940,10 +982,14 @@ type PanelColumnProps = { onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; uptimeFormatted: string; + versionText: string; + versionStatus: "up_to_date" | "requires_update" | "beta"; + dbHealth: "healthy" | "error"; credentialCount: number; activeTunnelCount: number; activity: RecentActivityItem[]; onClearActivity: () => void; + cardLabels: Record; }; function PanelColumn({ @@ -961,11 +1007,16 @@ function PanelColumn({ onOpenTab, hosts, uptimeFormatted, + versionText, + versionStatus, + dbHealth, credentialCount, activeTunnelCount, activity, onClearActivity, + cardLabels, }: PanelColumnProps) { + const { t } = useTranslation(); const sorted = [...slots].sort((a, b) => a.order - b.order); const allIds = slots.map((s) => s.id); @@ -1007,6 +1058,9 @@ function PanelColumn({ onOpenTab={onOpenTab} hosts={hosts} uptimeFormatted={uptimeFormatted} + versionText={versionText} + versionStatus={versionStatus} + dbHealth={dbHealth} credentialCount={credentialCount} activeTunnelCount={activeTunnelCount} activity={activity} @@ -1022,11 +1076,15 @@ function PanelColumn({ active={!!dragState} /> {editMode && ( - onAdd(id, panel)} /> + onAdd(id, panel)} + cardLabels={cardLabels} + /> )} {sorted.length === 0 && !editMode && (
- Empty + {t("dashboardTab.empty")}
)}
@@ -1061,6 +1119,7 @@ export function DashboardTab({ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; }) { + const { t, i18n } = useTranslation(); const [slots, setSlots] = useState(DEFAULT_SLOTS); const [editMode, setEditMode] = useState(false); const [dragState, setDragState] = useState(null); @@ -1068,6 +1127,11 @@ export function DashboardTab({ const [hosts, setHosts] = useState([]); const [uptimeFormatted, setUptimeFormatted] = useState(""); + const [versionText, setVersionText] = useState(""); + const [versionStatus, setVersionStatus] = useState< + "up_to_date" | "requires_update" | "beta" + >("up_to_date"); + const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy"); const [credentialCount, setCredentialCount] = useState(0); const [activeTunnelCount, setActiveTunnelCount] = useState(0); const [activity, setActivity] = useState([]); @@ -1079,6 +1143,23 @@ export function DashboardTab({ getUptime() .then((u) => setUptimeFormatted(u.formatted)) .catch(() => {}); + getVersionInfo() + .then((info) => { + setVersionText(info.localVersion ?? ""); + setVersionStatus(info.status ?? "up_to_date"); + }) + .catch(() => {}); + getDatabaseHealth() + .then((health) => { + setDbHealth( + health.status === "ok" || health.status === "healthy" + ? "healthy" + : "error", + ); + }) + .catch(() => { + setDbHealth("error"); + }); getRecentActivity(50) .then(setActivity) .catch(() => {}); @@ -1108,7 +1189,7 @@ export function DashboardTab({ } }; - const todayLabel = new Date().toLocaleDateString("en-US", { + const todayLabel = new Date().toLocaleDateString(i18n.language, { weekday: "long", month: "long", day: "numeric", @@ -1124,6 +1205,15 @@ export function DashboardTab({ .sort((a, b) => a.order - b.order); const hasSide = sideSlots.length > 0; + const cardLabels: Record = { + stats_bar: t("dashboard.serverOverview"), + counters_bar: t("dashboard.serverStats"), + quick_actions: t("dashboard.quickActions"), + host_status: t("dashboardTab.hostStatus"), + recent_activity: t("dashboard.recentActivity"), + network_graph: t("dashboard.networkGraph"), + }; + const onColumnDividerMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -1211,12 +1301,16 @@ export function DashboardTab({ const columnProps = { hosts, uptimeFormatted, + versionText, + versionStatus, + dbHealth, credentialCount, activeTunnelCount, activity, onClearActivity: handleClearActivity, onOpenSingletonTab, onOpenTab, + cardLabels, }; const isMobile = useIsMobile(); @@ -1228,7 +1322,9 @@ export function DashboardTab({
-

Dashboard

+

+ {t("dashboard.title")} +

{todayLabel}

@@ -1243,7 +1339,7 @@ export function DashboardTab({ target="_blank" rel="noreferrer" > - GitHub + {t("dashboard.github")}
@@ -1282,7 +1378,13 @@ export function DashboardTab({ className={`shrink-0 ${slot.id === "host_status" || slot.id === "recent_activity" ? "max-h-72 flex flex-col overflow-hidden" : ""}`} > {slot.id === "stats_bar" && ( - + )} {slot.id === "counters_bar" && (
-

Dashboard

+

+ {t("dashboard.title")} +

{todayLabel}

- Command Palette + {t("dashboardTab.commandPalette")}
Shift @@ -1344,7 +1448,7 @@ export function DashboardTab({ target="_blank" rel="noreferrer" > - GitHub + {t("dashboard.github")} @@ -1384,14 +1488,14 @@ export function DashboardTab({ className="text-xs text-muted-foreground" onClick={handleReset} > - Reset + {t("dashboard.reset")} ) : ( @@ -1399,7 +1503,7 @@ export function DashboardTab({ variant="ghost" size="icon" onClick={() => setEditMode(true)} - title="Customize Dashboard" + title={t("dashboard.customizeLayout")} > @@ -1411,9 +1515,7 @@ export function DashboardTab({
- Drag cards to reorder · Drag the column divider to resize columns · - Drag the bottom edge of a card to resize its height · Trash to - remove + {t("dashboardTab.editModeInstructions")}
)} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index 9c29c7f9..c1d02cb9 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -1594,15 +1594,44 @@ export function NetworkGraphCard({ {t("dashboard.networkGraph")}

- +
+ + + + +
setError(null)}> diff --git a/src/ui/dashboard/cards/RecentActivityCard.tsx b/src/ui/dashboard/cards/RecentActivityCard.tsx index 80304b5f..48731fcc 100644 --- a/src/ui/dashboard/cards/RecentActivityCard.tsx +++ b/src/ui/dashboard/cards/RecentActivityCard.tsx @@ -22,6 +22,21 @@ interface RecentActivityCardProps { onActivityClick: (item: RecentActivityItem) => void; } +function formatRelativeTime( + timestamp: string, + t: (key: string) => string, +): string { + const diffMs = Date.now() - new Date(timestamp).getTime(); + if (diffMs < 0) return t("dashboard.justNow"); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return t("dashboard.justNow"); + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}h`; + return `${Math.floor(diffHour / 24)}d`; +} + export function RecentActivityCard({ activities, loading, @@ -95,7 +110,14 @@ export function RecentActivityCard({ ) : ( )} -

{item.hostName}

+
+

+ {item.hostName} +

+

+ {formatRelativeTime(item.timestamp, t)} +

+
)) )} diff --git a/src/ui/dashboard/cards/ServerOverviewCard.tsx b/src/ui/dashboard/cards/ServerOverviewCard.tsx index 775cd3b5..019b3d95 100644 --- a/src/ui/dashboard/cards/ServerOverviewCard.tsx +++ b/src/ui/dashboard/cards/ServerOverviewCard.tsx @@ -56,22 +56,28 @@ export function ServerOverviewCard({

{versionText}

- {!updateCheckDisabled && ( + {versionStatus === "beta" ? ( + + ) : !updateCheckDisabled ? ( <> - )} + ) : null}
diff --git a/src/ui/dashboard/cards/ServerStatsCard.tsx b/src/ui/dashboard/cards/ServerStatsCard.tsx index 662da0a9..c95dcf5c 100644 --- a/src/ui/dashboard/cards/ServerStatsCard.tsx +++ b/src/ui/dashboard/cards/ServerStatsCard.tsx @@ -23,6 +23,10 @@ export function ServerStatsCard({ }: ServerStatsCardProps): React.ReactElement { const { t } = useTranslation(); + const visibleStats = serverStats.filter( + (s) => s.cpu !== null || s.ram !== null, + ); + return (
@@ -38,12 +42,12 @@ export function ServerStatsCard({ {t("dashboard.loadingServerStats")}
- ) : serverStats.length === 0 ? ( + ) : visibleStats.length === 0 ? (

{t("dashboard.noServerData")}

) : ( - serverStats.map((server) => ( + visibleStats.map((server) => (
- - {t("dashboard.cpu")}:{" "} - {server.cpu !== null - ? `${server.cpu}%` - : t("dashboard.notAvailable")} - - - {t("dashboard.ram")}:{" "} - {server.ram !== null - ? `${server.ram}%` - : t("dashboard.notAvailable")} - + {server.cpu !== null && ( + + {t("dashboard.cpu")}: {server.cpu.toFixed(1)}% + + )} + {server.ram !== null && ( + + {t("dashboard.ram")}: {server.ram.toFixed(1)}% + + )}
diff --git a/src/ui/dashboard/hooks/useDashboardPreferences.ts b/src/ui/dashboard/hooks/useDashboardPreferences.ts index 3b262d78..7860e227 100644 --- a/src/ui/dashboard/hooks/useDashboardPreferences.ts +++ b/src/ui/dashboard/hooks/useDashboardPreferences.ts @@ -5,6 +5,8 @@ import { type DashboardLayout, } from "@/main-axios"; +const LS_KEY = "dashboardLayout"; + const DEFAULT_LAYOUT: DashboardLayout = { cards: [ { id: "server_overview", enabled: true, order: 1, panel: "main" }, @@ -16,6 +18,42 @@ const DEFAULT_LAYOUT: DashboardLayout = { mainWidthPct: 68, }; +function migrateLayout(preferences: DashboardLayout): DashboardLayout { + const needsMigration = preferences.cards.some((c) => !c.panel); + if (!needsMigration) return preferences; + const defaultCardMap = new Map(DEFAULT_LAYOUT.cards.map((c) => [c.id, c])); + return { + ...preferences, + mainWidthPct: preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct, + cards: preferences.cards.map((c) => ({ + ...c, + panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main", + })), + }; +} + +function readFromLocalStorage(): DashboardLayout | null { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed?.cards && Array.isArray(parsed.cards)) { + return migrateLayout(parsed); + } + } catch { + // ignore + } + return null; +} + +function writeToLocalStorage(layout: DashboardLayout) { + try { + localStorage.setItem(LS_KEY, JSON.stringify(layout)); + } catch { + // ignore + } +} + export function useDashboardPreferences(enabled: boolean = true) { const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); @@ -28,34 +66,29 @@ export function useDashboardPreferences(enabled: boolean = true) { return; } + // Show cached layout immediately so the UI doesn't wait for the network + const cached = readFromLocalStorage(); + if (cached) { + setLayout(cached); + setLoading(false); + } + const fetchPreferences = async () => { try { const preferences = await getDashboardPreferences(); if (preferences?.cards && Array.isArray(preferences.cards)) { - // Migrate old layouts that don't have panel assignments - const needsMigration = preferences.cards.some((c) => !c.panel); - if (needsMigration) { - const defaultCardMap = new Map( - DEFAULT_LAYOUT.cards.map((c) => [c.id, c]), - ); - const migrated: DashboardLayout = { - ...preferences, - mainWidthPct: - preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct, - cards: preferences.cards.map((c) => ({ - ...c, - panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main", - })), - }; - setLayout(migrated); - } else { - setLayout(preferences); - } + const migrated = migrateLayout(preferences); + setLayout(migrated); + writeToLocalStorage(migrated); } else { - setLayout(DEFAULT_LAYOUT); + if (!cached) { + setLayout(DEFAULT_LAYOUT); + } } } catch { - setLayout(DEFAULT_LAYOUT); + if (!cached) { + setLayout(DEFAULT_LAYOUT); + } } finally { setLoading(false); } @@ -67,6 +100,7 @@ export function useDashboardPreferences(enabled: boolean = true) { const updateLayout = useCallback( (newLayout: DashboardLayout) => { setLayout(newLayout); + writeToLocalStorage(newLayout); if (saveTimeout) { clearTimeout(saveTimeout); @@ -87,6 +121,7 @@ export function useDashboardPreferences(enabled: boolean = true) { const resetLayout = useCallback(async () => { setLayout(DEFAULT_LAYOUT); + writeToLocalStorage(DEFAULT_LAYOUT); try { await saveDashboardPreferences(DEFAULT_LAYOUT); } catch (error) { diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 3476a757..41a142db 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -529,7 +529,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } handleCloseWithError( - t("fileManager.failedToConnect") + ": " + (error.message || error), + t("fileManager.failedToConnect") + + ": " + + (error instanceof Error ? error.message : String(error)), ); } finally { setIsLoading(false); @@ -837,10 +839,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { handleRefreshDirectory(); } catch (error: unknown) { toast.dismiss(progressToast); - + const uploadErr = error instanceof Error ? error : null; if ( - error.message?.includes("connection") || - error.message?.includes("established") + uploadErr?.message?.includes("connection") || + uploadErr?.message?.includes("established") ) { toast.error( t("fileManager.sshConnectionFailed", { @@ -864,21 +866,25 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const response = await downloadSSHFile(sshSessionId, file.path); - if (response?.content) { - const byteCharacters = atob(response.content); + const content = response?.content as string | undefined; + const mimeType = response?.mimeType as string | undefined; + const fileName = response?.fileName as string | undefined; + + if (content) { + const byteCharacters = atob(content); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); const blob = new Blob([byteArray], { - type: response.mimeType || "application/octet-stream", + type: mimeType || "application/octet-stream", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = response.fileName || file.name; + link.download = fileName || file.name; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -891,9 +897,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { toast.error(t("fileManager.failedToDownloadFile")); } } catch (error: unknown) { + const err = error instanceof Error ? error : null; if ( - error.message?.includes("connection") || - error.message?.includes("established") + err?.message?.includes("connection") || + err?.message?.includes("established") ) { toast.error( t("fileManager.sshConnectionFailed", { @@ -977,7 +984,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { clearSelection(); } catch (error: unknown) { const axiosError = error as { - response?: { data?: { needsSudo?: boolean; error?: string } }; + response?: { + data?: { needsSudo?: boolean; error?: string }; + status?: number; + }; message?: string; }; if (axiosError.response?.data?.needsSudo) { @@ -986,11 +996,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { return; } if ( + axiosError.response?.status === 403 || + axiosError.response?.data?.error + ?.toLowerCase() + .includes("permission denied") + ) { + toast.error(t("fileManager.permissionDenied")); + } else if ( axiosError.message?.includes("connection") || axiosError.message?.includes("established") ) { toast.error( - `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, + t("fileManager.sshConnectionFailed", { + name: currentHost?.name, + ip: currentHost?.ip, + port: currentHost?.port, + }), ); } else { toast.error(t("fileManager.failedToDeleteItems")); @@ -1305,16 +1326,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } catch (error: unknown) { console.error(`Failed to ${operation} file ${file.name}:`, error); - toast.error( - t("fileManager.operationFailed", { - operation: - operation === "copy" - ? t("fileManager.copy") - : t("fileManager.move"), - name: file.name, - error: error.message, - }), - ); + const axiosError = error as { + response?: { status?: number; data?: { error?: string } }; + }; + if ( + axiosError.response?.status === 403 || + axiosError.response?.data?.error + ?.toLowerCase() + .includes("permission denied") + ) { + toast.error(t("fileManager.permissionDenied")); + } else { + toast.error( + t("fileManager.operationFailed", { + operation: + operation === "copy" + ? t("fileManager.copy") + : t("fileManager.move"), + name: file.name, + error: error instanceof Error ? error.message : String(error), + }), + ); + } } } @@ -1405,9 +1438,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setClipboard(null); } } catch (error: unknown) { - toast.error( - `${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`, - ); + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`); } } @@ -1433,10 +1466,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { handleRefreshDirectory(); } catch (error: unknown) { - const err = error as { message?: string }; - toast.error( - `${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`, - ); + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`); } } @@ -1478,10 +1510,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { handleRefreshDirectory(); clearSelection(); } catch (error: unknown) { - const err = error as { message?: string }; - toast.error( - `${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`, - ); + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`); } } @@ -1521,7 +1552,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { toast.error( t("fileManager.deleteCopiedFileFailed", { name: copiedFile.targetName, - error: error.message, + error: + error instanceof Error ? error.message : String(error), }), ); } @@ -1563,7 +1595,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { toast.error( t("fileManager.moveBackFileFailed", { name: movedFile.targetName, - error: error.message, + error: + error instanceof Error ? error.message : String(error), }), ); } @@ -1596,9 +1629,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { handleRefreshDirectory(); } catch (error: unknown) { - toast.error( - `${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`, - ); + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`); console.error("Undo failed:", error); } } @@ -1693,8 +1726,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setCreateIntent(null); handleRefreshDirectory(); } catch (error: unknown) { + const axiosError = error as { + response?: { status?: number; data?: { error?: string } }; + }; + if ( + axiosError.response?.status === 403 || + axiosError.response?.data?.error + ?.toLowerCase() + .includes("permission denied") + ) { + toast.error(t("fileManager.permissionDenied")); + } else { + toast.error(t("fileManager.failedToCreateItem")); + } console.error("Create failed:", error); - toast.error(t("fileManager.failedToCreateItem")); } } @@ -1722,8 +1767,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setEditingFile(null); handleRefreshDirectory(); } catch (error: unknown) { + const axiosError = error as { + response?: { status?: number; data?: { error?: string } }; + }; + if ( + axiosError.response?.status === 403 || + axiosError.response?.data?.error + ?.toLowerCase() + .includes("permission denied") + ) { + toast.error(t("fileManager.permissionDenied")); + } else { + toast.error(t("fileManager.failedToRenameItem")); + } console.error("Rename failed:", error); - toast.error(t("fileManager.failedToRenameItem")); } } @@ -1907,7 +1964,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setAuthDialogReason("auth_failed"); setShowAuthDialog(true); toast.error( - t("fileManager.failedToConnect") + ": " + (error.message || error), + t("fileManager.failedToConnect") + + ": " + + (error instanceof Error ? error.message : String(error)), ); } finally { setIsLoading(false); @@ -1976,7 +2035,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { toast.error( t("fileManager.moveFileFailed", { name: file.name }) + ": " + - error.message, + (error instanceof Error ? error.message : String(error)), ); } } @@ -2019,7 +2078,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } catch (error: unknown) { console.error("Drag move operation failed:", error); - toast.error(t("fileManager.moveOperationFailed") + ": " + error.message); + toast.error( + t("fileManager.moveOperationFailed") + + ": " + + (error instanceof Error ? error.message : String(error)), + ); } } @@ -2093,7 +2156,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { toast.error( t("fileManager.dragFailed") + ": " + - (error.message || t("fileManager.unknownError")), + (error instanceof Error ? error.message : String(error)), ); } } @@ -2359,6 +2422,25 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); } + if ((isLoading || isReconnecting) && !sshSessionId) { + return ( +
+
+ +
+ +
+ ); + } + return (
- { - const menuWidth = menuRef.current?.offsetWidth ?? 260; - const menuHeight = menuRef.current?.offsetHeight ?? 400; - setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight)); - }; - - adjustPosition(); - let cleanupFn: (() => void) | null = null; const timeoutId = setTimeout(() => { @@ -206,6 +198,21 @@ export function FileManagerContextMenu({ }; }, [isVisible, x, y, onClose]); + useLayoutEffect(() => { + if (!isVisible || !menuRef.current) return; + const menuWidth = menuRef.current.offsetWidth; + const menuHeight = menuRef.current.offsetHeight; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let adjustedX = x; + let adjustedY = y; + if (x + menuWidth > viewportWidth) + adjustedX = viewportWidth - menuWidth - 10; + if (y + menuHeight > viewportHeight) + adjustedY = Math.max(10, viewportHeight - menuHeight - 10); + setMenuPosition({ x: adjustedX, y: adjustedY }); + }, [isVisible, x, y, files.length]); + const isFileContext = files.length > 0; const isSingleFile = files.length === 1; const isMultipleFiles = files.length > 1; @@ -569,7 +576,9 @@ export function FileManagerContextMenu({ >
{item.icon}
- {item.label} + + {item.label} +
{item.shortcut && (
diff --git a/src/ui/features/file-manager/components/PdfPreview.tsx b/src/ui/features/file-manager/components/PdfPreview.tsx index b13ec2ef..b678f626 100644 --- a/src/ui/features/file-manager/components/PdfPreview.tsx +++ b/src/ui/features/file-manager/components/PdfPreview.tsx @@ -4,7 +4,8 @@ import { AlertCircle, Download } from "lucide-react"; import { Button } from "@/components/button.tsx"; import { useTranslation } from "react-i18next"; -pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js"; +import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; interface PdfPreviewProps { content: string; diff --git a/src/ui/features/file-manager/components/TerminalWindow.tsx b/src/ui/features/file-manager/components/TerminalWindow.tsx index e4666b7f..55c17063 100644 --- a/src/ui/features/file-manager/components/TerminalWindow.tsx +++ b/src/ui/features/file-manager/components/TerminalWindow.tsx @@ -3,6 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx"; import { Terminal } from "@/features/terminal/Terminal.tsx"; import { useWindowManager } from "./WindowManager.tsx"; import { useTranslation } from "react-i18next"; +import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx"; interface SSHHost { id: number; @@ -96,29 +97,31 @@ export function TerminalWindow({ : t("terminal.terminalTitle", { host: hostConfig.name }); return ( - - + - + onMaximize={handleMaximize} + onFocus={handleFocus} + onResize={handleResize} + isMaximized={currentWindow.isMaximized} + zIndex={currentWindow.zIndex} + > + + + ); } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 7abb48e5..ec2c0d51 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1679,6 +1679,7 @@ "connectionLogClear": "Clear logs", "connectionLogEmpty": "No connection logs yet", "connectionLogConnecting": "Connecting to host...", + "connectionLogWaiting": "Waiting for connection logs...", "connectionLogCopied": "Connection logs copied to clipboard", "connectionLogCopyFailed": "Failed to copy logs to clipboard", "allAuthMethodsFailed": "All authentication methods failed. Please check your credentials.", @@ -2774,7 +2775,31 @@ "quickActionsCard": "Quick Actions", "serverStatsCard": "Server Stats", "panelMain": "Main", - "panelSide": "Side" + "panelSide": "Side", + "justNow": "just now" + }, + "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", + "manageYourAccount": "Manage your account", + "hostStatus": "Host Status", + "noHostsConfigured": "No hosts configured", + "online": "ONLINE", + "offline": "OFFLINE", + "onlineLower": "Online", + "offlineLower": "Offline", + "nodes": "{{count}} nodes", + "noHostsToDisplay": "No hosts to display", + "add": "Add:", + "commandPalette": "Command Palette", + "done": "Done", + "editModeInstructions": "Drag cards to reorder · Drag the column divider to resize columns · Drag the bottom edge of a card to resize its height · Trash to remove", + "empty": "Empty", + "clear": "Clear" }, "networkGraph": { "title": "Network Graph", diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index 8b4f7501..0ac61d59 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -69,7 +69,7 @@ export function TabBar({ const barRect = tabBarRef.current.getBoundingClientRect(); const x = Math.max( barRect.left, - Math.min(barRect.right - d.width, e.clientX - d.offsetX), + Math.min(barRect.right - d.width - 4, e.clientX - d.offsetX), ); const y = d.barTop; setDragPos({ x, y }); diff --git a/src/ui/sidebar/HostManager.tsx b/src/ui/sidebar/HostManager.tsx index cbc60222..f01248e6 100644 --- a/src/ui/sidebar/HostManager.tsx +++ b/src/ui/sidebar/HostManager.tsx @@ -63,6 +63,23 @@ import { deleteSSHHost, createCredential, updateCredential, + deleteCredential, + getAllServerStatuses, + getServerMetricsById, + bulkImportSSHHosts, + bulkUpdateSSHHosts, + generateKeyPair, + generatePublicKeyFromPrivate, + deployCredentialToHost, + getSnippets, + getUserList, + getRoles, + shareHost, + getHostAccess, + revokeHostAccess, + renameFolder, + refreshServerPolling, + deleteAllHostsInFolder, } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; @@ -75,8 +92,8 @@ function sshHostToHost(h: SSHHostWithStatus): Host { port: h.port, folder: h.folder ?? "", online: h.status === "online", - cpu: 0, - ram: 0, + cpu: null, + ram: null, lastAccess: "", tags: h.tags ?? [], authType: h.authType, @@ -93,9 +110,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host { enableTunnel: h.enableTunnel ?? false, enableFileManager: h.enableFileManager ?? false, enableDocker: h.enableDocker ?? false, - enableRdp: h.connectionType === "rdp", - enableVnc: h.connectionType === "vnc", - enableTelnet: h.connectionType === "telnet", + enableRdp: h.enableRdp ?? h.connectionType === "rdp", + enableVnc: h.enableVnc ?? h.connectionType === "vnc", + enableTelnet: h.enableTelnet ?? h.connectionType === "telnet", sshPort: h.port, rdpPort: 3389, vncPort: 5900, @@ -232,6 +249,8 @@ function HostRow({ const hasSsh = host.enableSsh; + const metricsEnabled = host.statsConfig?.metricsEnabled !== false; + const sshActions: { type: string; icon: typeof Terminal; label: string }[] = [ host.enableTerminal && { type: "terminal", @@ -245,7 +264,7 @@ function HostRow({ }, host.enableDocker && { type: "docker", icon: Box, label: "Docker" }, host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" }, - { type: "stats", icon: Server, label: "Stats" }, + metricsEnabled && { type: "stats", icon: Server, label: "Stats" }, ].filter(Boolean) as { type: string; icon: typeof Terminal; label: string }[]; const fireOpen = (type: string) => { @@ -280,7 +299,7 @@ function HostRow({ {/* Status dot */}
{/* Name + badges */} @@ -325,34 +344,42 @@ function HostRow({ {/* Right: last access always visible, CPU/RAM on hover */}
- {host.online && hovered && ( -
-
- CPU -
-
80 ? "bg-red-400" : host.cpu! > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} - style={{ width: `${host.cpu}%` }} - /> + {host.online && + hovered && + metricsEnabled && + host.cpu != null && + host.ram != null && ( +
+
+ + CPU + +
+
80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} + style={{ width: `${host.cpu}%` }} + /> +
+ + {host.cpu}% +
- - {host.cpu}% - -
-
- RAM -
-
80 ? "bg-red-400" : host.ram! > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} - style={{ width: `${host.ram}%` }} - /> +
+ + RAM + +
+
80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} + style={{ width: `${host.ram}%` }} + /> +
+ + {host.ram}% +
- - {host.ram}% -
-
- )} + )} {host.lastAccess} @@ -390,7 +417,7 @@ function HostRow({
{/* Hover action tray */} - {hovered && !selectionMode && ( + {(hovered || selectionMode) && (
0 ? `-${depth * 12 + 8}px` : undefined }} @@ -594,7 +621,8 @@ function HostEditor({ keyPassword: host?.keyPassword ?? "", credentialId: host?.credentialId ?? "", folder: host?.folder ?? "", - tags: host?.tags?.join(" ") ?? "", + tags: host?.tags ?? ([] as string[]), + tagInput: "", notes: host?.notes ?? "", pin: host?.pin ?? false, macAddress: host?.macAddress ?? "", @@ -607,7 +635,7 @@ function HostEditor({ enableFileManager: host?.enableFileManager ?? false, enableDocker: host?.enableDocker ?? false, enableTunnel: host?.enableTunnel ?? false, - defaultPath: host?.defaultPath ?? "~", + defaultPath: host?.defaultPath ?? "/", fontSize: host?.terminalConfig?.fontSize ?? 14, fontFamily: host?.terminalConfig?.fontFamily ?? "JetBrains Mono", theme: host?.terminalConfig?.theme ?? "Termix Dark", @@ -668,14 +696,63 @@ function HostEditor({ setForm((p) => ({ ...p, [k]: v })); const [saving, setSaving] = useState(false); + const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]); + const [shareType, setShareType] = useState<"user" | "role">("user"); + const [shareGranteeId, setShareGranteeId] = useState(""); + const [sharePermission, setSharePermission] = useState("view"); + const [shareExpiryHours, setShareExpiryHours] = useState(""); + const [accessList, setAccessList] = useState([]); + const [shareUsers, setShareUsers] = useState< + { id: string; username: string }[] + >([]); + const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>( + [], + ); + const [sharingLoaded, setSharingLoaded] = useState(false); + + useEffect(() => { + getSnippets() + .then((res: any) => { + const arr = Array.isArray(res) ? res : (res?.snippets ?? []); + setSnippets( + arr.map((s: any) => ({ + id: s.id, + name: s.name ?? s.title ?? `Snippet ${s.id}`, + })), + ); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + if (activeTab !== "sharing" || !host) return; + if (sharingLoaded) return; + setSharingLoaded(true); + Promise.all([ + getHostAccess(Number(host.id)).catch(() => ({ access: [] })), + getUserList().catch(() => ({ users: [] })), + getRoles().catch(() => ({ roles: [] })), + ]).then(([accessRes, usersRes, rolesRes]) => { + setAccessList((accessRes as any)?.access ?? []); + setShareUsers( + ((usersRes as any)?.users ?? []).map((u: any) => ({ + id: String(u.id ?? u.userId), + username: u.username, + })), + ); + setShareRoles( + ((rolesRes as any)?.roles ?? []).map((r: any) => ({ + id: String(r.id), + name: r.name, + })), + ); + }); + }, [activeTab, host, sharingLoaded]); const handleSave = async () => { setSaving(true); try { - const tags = form.tags - .split(/\s+/) - .map((t) => t.trim()) - .filter(Boolean); + const tags = form.tags; const data = { connectionType: protocols.enableSsh ? "ssh" @@ -708,7 +785,7 @@ function HostEditor({ enableTunnel: form.enableTunnel, enableFileManager: form.enableFileManager, enableDocker: form.enableDocker, - defaultPath: form.defaultPath || "~", + defaultPath: form.defaultPath || "/", useSocks5: form.useSocks5, socks5Host: form.socks5Host || null, socks5Port: form.socks5Port || null, @@ -847,24 +924,6 @@ function HostEditor({ {desc} - {enabled && ( -
- - Port - - - setField( - portField, - Number(e.target.value) as any, - ) - } - className="h-6 w-16 text-[10px] px-2" - /> -
- )}
Tags - setField("tags", e.target.value)} - /> +
+ {form.tags.map((tag) => ( + + {tag} + + + ))} + setField("tagInput", e.target.value)} + onKeyDown={(e) => { + if ( + (e.key === " " || e.key === "Enter") && + form.tagInput.trim() + ) { + e.preventDefault(); + const tag = form.tagInput.trim(); + if (!form.tags.includes(tag)) + setField("tags", [...form.tags, tag]); + setField("tagInput", ""); + } else if ( + e.key === "Backspace" && + !form.tagInput && + form.tags.length > 0 + ) { + setField("tags", form.tags.slice(0, -1)); + } + }} + /> +
- { const updated = [...form.quickActions]; @@ -2139,7 +2280,14 @@ function HostEditor({ }; setField("quickActions", updated); }} - /> + > + + {snippets.map((s) => ( + + ))} +
)} - } - > -
-
- {["user", "role"].map((t) => ( - - ))} -
-
- - -
-
- -
- View only -
-
-
- - -
-
- -
-
-
- - } - > -
-
- Type - Target - Permission - Granted By - Expires - -
- {[ - { - type: "User", - target: "alice", - permission: "View", - grantedBy: "admin", - expires: "Never", - expired: false, - }, - { - type: "Role", - target: "Developers", - permission: "View", - grantedBy: "admin", - expires: "2026-06-01", - expired: false, - }, - { - type: "User", - target: "bob", - permission: "View", - grantedBy: "alice", - expires: "2026-04-01", - expired: true, - }, - ].map((r, i) => ( -
-
- {r.type === "User" ? ( - - ) : ( - - )} - {r.type} -
- {r.target} - {r.permission} - {r.grantedBy} - - {r.expired ? ( - - - Expired - - ) : ( - r.expires - )} - -
- -
+ {t === "user" ? ( + <> + + Share with User + + ) : ( + <> + + Share with Role + + )} + + ))}
- ))} -
-
+
+ + +
+
+ + +
+
+ + setShareExpiryHours(e.target.value)} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+ +
+
+ + )} + + {host !== null && ( + } + > +
+
+ Type + Target + Permission + Granted By + Expires + +
+ {accessList.length === 0 && ( +
+ No access entries yet. +
+ )} + {accessList.map((r: any, i: number) => { + const expired = + r.expiresAt && new Date(r.expiresAt) < new Date(); + return ( +
+
+ {r.granteeType === "user" ? ( + + ) : ( + + )} + + {r.granteeType} + +
+ + {r.granteeName ?? r.granteeId} + + {r.permission} + + {r.grantedBy ?? "—"} + + + {expired ? ( + + + Expired + + ) : r.expiresAt ? ( + new Date(r.expiresAt).toLocaleDateString() + ) : ( + "Never" + )} + +
+ +
+
+ ); + })} +
+
+ )} )}
@@ -3247,12 +3513,16 @@ function CredentialEditorView({ username: credential?.username ?? "", folder: credential?.folder ?? "", description: credential?.description ?? "", - tags: credential?.tags?.join(" ") ?? "", + tags: credential?.tags ?? ([] as string[]), + tagInput: "", type: credential?.type ?? "password", value: credential?.value ?? "", publicKey: credential?.publicKey ?? "", passphrase: credential?.passphrase ?? "", })); + const [generatingKey, setGeneratingKey] = useState(false); + const [generatingPublicKey, setGeneratingPublicKey] = useState(false); + const credFileInputRef = useRef(null); const setCredField = ( k: K, v: (typeof credForm)[K], @@ -3267,10 +3537,7 @@ function CredentialEditorView({ username: credForm.username, folder: credForm.folder || null, description: credForm.description || null, - tags: credForm.tags - .split(/\s+/) - .map((t) => t.trim()) - .filter(Boolean), + tags: credForm.tags, authType: credForm.type, password: credForm.type === "password" ? credForm.value : null, key: credForm.type === "key" ? credForm.value : null, @@ -3333,11 +3600,52 @@ function CredentialEditorView({ - setCredField("tags", e.target.value)} - /> +
+ {credForm.tags.map((tag) => ( + + {tag} + + + ))} + setCredField("tagInput", e.target.value)} + onKeyDown={(e) => { + if ( + (e.key === " " || e.key === "Enter") && + credForm.tagInput.trim() + ) { + e.preventDefault(); + const tag = credForm.tagInput.trim(); + if (!credForm.tags.includes(tag)) + setCredField("tags", [...credForm.tags, tag]); + setCredField("tagInput", ""); + } else if ( + e.key === "Backspace" && + !credForm.tagInput && + credForm.tags.length > 0 + ) { + setCredField("tags", credForm.tags.slice(0, -1)); + } + }} + /> +
@@ -3390,10 +3698,82 @@ function CredentialEditorView({ )} {type === "key" && (
+
+

+ Generate Key Pair +

+

+ Generate a new key pair — both private and public keys will + be filled automatically. +

+
+ {[ + { label: "Ed25519", type: "ssh-ed25519" }, + { label: "ECDSA (nistp256)", type: "ecdsa" }, + { label: "RSA (2048)", type: "rsa", bits: 2048 }, + ].map(({ label, type: keyType, bits }) => ( + + ))} +
+
- +
+ + +
+ { + const file = e.target.files?.[0]; + if (!file) return; + const text = await file.text(); + setCredField("value", text.trim()); + e.target.value = ""; + }} + />