import { useState, useRef, useCallback, useEffect } from "react"; import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols"; import { ComponentSlot } from "@/shell/ActionSlot"; import { useActionSlot } from "@/hooks/use-action-slot"; import { useIsMobile } from "@/hooks/use-mobile"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; import { Separator } from "@/components/separator"; import { Skeleton } from "@/components/skeleton"; import { Activity, Database, ExternalLink, GripHorizontal, GripVertical, KeyRound, LayoutDashboard, Plus, Server, Settings, Trash2, User, Zap, } from "lucide-react"; import { Kbd } from "@/components/kbd"; import { VersionBadge } from "@/components/version-badge"; import { DASHBOARD_CARDS } from "@/lib/theme"; import type { DashboardCardId, TabType, Host } from "@/types/ui-types"; import { getSSHHosts, getUptime, getVersionInfo, releaseUrlFrom, getDatabaseHealth, getRecentActivity, getCredentials, resetRecentActivity, getUserInfo, isElectron, } from "@/main-axios"; import type { RecentActivityItem } from "@/main-axios"; import { useTranslation } from "react-i18next"; import { getRegisteredDashboardCard, useRegisteredDashboardCards, } from "./dashboard-cards-registry"; import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; import { activityTarget } from "@/lib/activity-types"; import { shell } from "@/plugin-host/shell-bridge"; import { defaultConnectAction, hostActionsFor, listHostActions, } from "@/sidebar/host-contributions"; import { useStatusColorScheme, getStatusClasses, } from "@/hooks/use-status-color-scheme"; import { useServerStatus } from "@/lib/ServerStatusContext"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { getDefaultConnectionTab } from "@/lib/host-connection-tabs"; // ─── Types ──────────────────────────────────────────────────────────────────── type PanelId = "main" | "side"; type CardSlot = { key: string; id: DashboardCardId; panel: PanelId; order: number; height: number | null; }; type DragState = { key: string; id: DashboardCardId; sourcePanel: PanelId; sourceOrder: number; } | null; // ─── Default layout ─────────────────────────────────────────────────────────── const DEFAULT_SLOTS: CardSlot[] = [ { key: "stats_bar_0", id: "stats_bar", panel: "main", order: 0, height: 96 }, { key: "counters_bar_0", id: "counters_bar", panel: "main", order: 1, height: 48, }, { key: "quick_actions_0", id: "quick_actions", panel: "main", order: 2, height: 160, }, { key: "host_status_0", id: "host_status", panel: "main", order: 3, height: null, }, { key: "recent_activity_0", id: "recent_activity", panel: "side", order: 0, height: null, }, ]; /** Cards core draws itself; any other id belongs to a plugin. */ const CORE_CARD_IDS = new Set([ "stats_bar", "counters_bar", "quick_actions", "host_status", "recent_activity", ]); /** * A plugin's card, or a placeholder that keeps the slot while its plugin is * off, so the card comes back in the same place when the plugin does. */ export function PluginCardSlot({ id, isVisible, onOpenSingletonTab, }: { id: string; isVisible: boolean; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; }) { useRegisteredDashboardCards(); const card = getRegisteredDashboardCard(id); if (!card) { return ( ); } const Component = card.component; return ( onOpenSingletonTab(type), }} /> ); } // ─── Card components ────────────────────────────────────────────────────────── function StatsBarCard({ hosts, uptimeFormatted, versionText, versionStatus, releaseUrl, dbHealth, }: { hosts: Host[]; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta" | "unknown"; releaseUrl: string; dbHealth: "healthy" | "error"; }) { const { t } = useTranslation(); const online = hosts.filter((h) => h.status === "online").length; return (
{t("dashboard.version")} {versionText || "—"}
{t("dashboard.uptime")} {uptimeFormatted || "—"}
{t("dashboard.database")} {dbHealth === "healthy" ? t("dashboard.healthy") : t("dashboard.error")}
{t("dashboardTab.hostsAvailable", { defaultValue: "Hosts Available", })}
{online} /{hosts.length}
); } function CountersBarCard({ hosts, credentialCount, onOpenSingletonTab, }: { hosts: Host[]; credentialCount: number; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; }) { const { t } = useTranslation(); return ( ); } function QuickActionsCard({ onOpenSingletonTab, hosts, onOpenTab, isAdmin, }: { onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; hosts: Host[]; onOpenTab: (host: Host, type: TabType) => void; isAdmin: boolean; }) { const { t } = useTranslation(); const pinnedHosts = hosts.filter((h) => h.pin); const getConnectionEndpoint = (host: Host) => { const protocol = host.enableSsh ? undefined : enabledHostProtocols(host)[0]; const port = host.enableSsh ? host.sshPort : protocol ? protocolPort(host.pluginSettings, protocol) : host.port; return `${host.ip}:${port ?? host.port}`; }; const renderConnectionIcon = (host: Host) => { const Icon = defaultConnectAction(listHostActions(), host)?.icon ?? Server; return ; }; return (
{t("dashboard.quickActions")}
{pinnedHosts.length > 0 ? (
{pinnedHosts.slice(0, 4).map((host) => ( ))}
) : ( <> {isAdmin && ( )} )}
); } export function HostStatusCard({ hosts, onOpenTab, statusLoading, }: { hosts: Host[]; onOpenTab: (host: Host, type: TabType) => void; statusLoading?: boolean; }) { const { t } = useTranslation(); const statusScheme = useStatusColorScheme(); const online = hosts.filter((h) => h.status === "online").length; return (
{t("dashboardTab.hostStatus")}
{online}/{hosts.length} {t("hosts.status.available")}
{hosts.length === 0 && (
{t("dashboardTab.noHostsConfigured")}
)} {hosts.map((host, i) => { const availability = host.status && host.status !== "unknown" ? host.status : host.online ? "online" : "offline"; return (
{ // An overview action (a metrics view) wins over connecting. const actions = hostActionsFor(listHostActions(), host); const target = actions.find((action) => action.overview)?.tabType ?? defaultConnectAction(actions, host)?.tabType; if (target) onOpenTab(host, target); }} className="flex min-w-0 items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row" >
{host.name}
{host.ip}
{/* Plugins add live details to a host row here. */} {statusLoading ? t("dashboardTab.checking") : availability === "online" ? t("hosts.status.available") : availability === "reachable" ? t("hosts.status.reachable") : t("hosts.status.offline")}
); })}
); } function isStatusCheckEnabled(host: Host): boolean { return host.statusCheckEnabled !== false; } function RecentActivityCard({ activity, hosts, onOpenTab, onClear, statusLoading, }: { activity: RecentActivityItem[]; hosts: Host[]; onOpenTab: (host: Host, type: TabType) => void; onClear: () => void; statusLoading?: boolean; }) { const { t } = useTranslation(); const statusScheme = useStatusColorScheme(); const typeIcon = (type: string): React.ReactNode => { const Icon = activityTarget(type)?.icon ?? Server; return ; }; const typeLabel = (type: string): string => { const labelKey = activityTarget(type)?.labelKey; return labelKey ? t(labelKey) : type.replace("_", " "); }; function formatTime(ts: string) { const diffMs = Date.now() - new Date(ts).getTime(); 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 (
{t("dashboard.recentActivity")}
{activity.length === 0 && (
{t("dashboard.noRecentActivity")}
)} {activity.map((item) => { const host = hosts.find((h) => h.id === String(item.hostId)); return (
{ const target = activityTarget(item.type); if (host && target) onOpenTab(host, target.tab); }} className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer" >
{item.hostName}
{typeIcon(item.type)} {typeLabel(item.type)}
{formatTime(item.timestamp)}
); })}
); } // ─── CardItem ───────────────────────────────────────────────────────────────── function CardItem({ slot, editMode, isDragging, onDragStart, onDrop, onDragOver, onRemove, onHeightChange, onOpenSingletonTab, onOpenTab, hosts, uptimeFormatted, versionText, versionStatus, releaseUrl, dbHealth, credentialCount, activity, onClearActivity, isAdmin, statusLoading, isVisible = true, }: { slot: CardSlot; editMode: boolean; isDragging: boolean; onDragStart: () => void; onDrop: () => void; onDragOver: (e: React.DragEvent) => void; onRemove: () => void; onHeightChange: (key: string, h: number) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta" | "unknown"; releaseUrl: string; dbHealth: "healthy" | "error"; credentialCount: number; activity: RecentActivityItem[]; onClearActivity: () => void; isAdmin: boolean; statusLoading?: boolean; isVisible?: boolean; }) { const cardRef = useRef(null); const onResizeMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const startY = e.clientY; const startH = cardRef.current?.getBoundingClientRect().height ?? 100; const onMove = (ev: MouseEvent) => { onHeightChange(slot.key, Math.max(50, startH + (ev.clientY - startY))); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, [slot.key, onHeightChange], ); const isFlex = slot.height === null; return (
{editMode && (
)} {editMode && (
)}
{slot.id === "stats_bar" && ( )} {slot.id === "counters_bar" && ( )} {slot.id === "quick_actions" && ( )} {slot.id === "host_status" && ( )} {slot.id === "recent_activity" && ( )} {!CORE_CARD_IDS.has(slot.id) && ( )}
{editMode && !isFlex && (
)}
); } // ─── DropZone ───────────────────────────────────────────────────────────────── function DropZone({ panel, order, onDrop, onDragOver, active, }: { panel: PanelId; order: number; onDrop: (panel: PanelId, order: number) => void; onDragOver: (e: React.DragEvent) => void; active: boolean; }) { const [over, setOver] = useState(false); if (!active) return null; return (
{ onDragOver(e); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={() => { setOver(false); onDrop(panel, order); }} /> ); } // ─── AddCardTray ────────────────────────────────────────────────────────────── function AddCardTray({ activeIds, onAdd, cardLabels, }: { activeIds: string[]; onAdd: (id: string) => void; cardLabels: Record; }) { const { t } = useTranslation(); const registeredCards = useRegisteredDashboardCards(); const available = [ ...DASHBOARD_CARDS.map((card) => ({ id: card.id as string })), ...registeredCards.map((card) => ({ id: card.id })), ].filter((c) => !activeIds.includes(c.id)); if (available.length === 0) return null; return (
{t("dashboardTab.add")} {available.map((card) => ( ))}
); } // ─── PanelColumn ───────────────────────────────────────────────────────────── type PanelColumnProps = { panel: PanelId; slots: CardSlot[]; editMode: boolean; dragState: DragState; onDragStart: (slot: CardSlot) => void; onDrop: (targetPanel: PanelId, targetOrder: number) => void; onDragOver: (e: React.DragEvent) => void; onRemove: (key: string) => void; onAdd: (id: DashboardCardId, panel: PanelId) => void; onHeightChange: (key: string, h: number) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta" | "unknown"; releaseUrl: string; dbHealth: "healthy" | "error"; credentialCount: number; activity: RecentActivityItem[]; onClearActivity: () => void; cardLabels: Record; isAdmin: boolean; statusLoading: boolean; isVisible?: boolean; }; function PanelColumn({ panel, slots, editMode, dragState, onDragStart, onDrop, onDragOver, onRemove, onAdd, onHeightChange, onOpenSingletonTab, onOpenTab, hosts, uptimeFormatted, versionText, versionStatus, releaseUrl, dbHealth, credentialCount, activity, onClearActivity, cardLabels, isAdmin, statusLoading, isVisible = true, }: PanelColumnProps) { const { t } = useTranslation(); const sorted = [...slots].sort((a, b) => a.order - b.order); const allIds = slots.map((s) => s.id); return (
{sorted.map((slot, idx) => (
{idx > 0 && (
)} onDragStart(slot)} onDrop={() => onDrop(slot.panel, slot.order)} onDragOver={onDragOver} onRemove={() => onRemove(slot.key)} onHeightChange={onHeightChange} onOpenSingletonTab={onOpenSingletonTab} onOpenTab={onOpenTab} hosts={hosts} uptimeFormatted={uptimeFormatted} versionText={versionText} versionStatus={versionStatus} releaseUrl={releaseUrl} dbHealth={dbHealth} credentialCount={credentialCount} activity={activity} onClearActivity={onClearActivity} isAdmin={isAdmin} statusLoading={statusLoading} isVisible={isVisible} />
))} {editMode && ( onAdd(id, panel)} cardLabels={cardLabels} /> )} {sorted.length === 0 && !editMode && (
{t("dashboardTab.empty")}
)}
); } function ColumnDivider({ onMouseDown, }: { onMouseDown: (e: React.MouseEvent) => void; }) { return (
); } // ─── DashboardTab ───────────────────────────────────────────────────────────── export function DashboardTab({ onOpenSingletonTab, onOpenTab, isVisible = true, }: { onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; /** When false, pause dashboard metrics refresh while the tab stays mounted. */ isVisible?: boolean; }) { const registeredCards = useRegisteredDashboardCards(); const { t, i18n } = useTranslation(); const { initialLoadComplete } = useServerStatus(); const statusLoading = !initialLoadComplete; const [slots, setSlots] = useState(() => { try { const saved = localStorage.getItem("dashboardTab.slots"); if (saved) { const parsed = JSON.parse(saved) as CardSlot[]; return parsed.map((s, i) => ({ key: s.key ?? `${s.id}_${i}`, ...s })); } } catch { /* ignore */ } return DEFAULT_SLOTS; }); // Picking an interface preset rewrites the stored layout from the settings // panel, so pick it up without waiting for a remount. useEffect(() => { const handler = () => { try { const saved = localStorage.getItem("dashboardTab.slots"); if (!saved) return; const parsed = JSON.parse(saved) as CardSlot[]; setSlots( parsed.map((s, i) => ({ key: s.key ?? `${s.id}_${i}`, ...s })), ); } catch { /* ignore */ } }; window.addEventListener("dashboardSlotsChanged", handler); return () => window.removeEventListener("dashboardSlotsChanged", handler); }, []); // A plugin's own view next to the dashboard, e.g. the homepage plugin's // canvas preview. Only one is offered today; "dashboard" always exists. const secondaryViews = useActionSlot("dashboard.secondaryView"); const secondaryView = secondaryViews[0]; const { settled: pluginsSettled } = usePluginStore(); const [dashboardView, setDashboardView] = useState(() => { try { return localStorage.getItem("dashboardView") ?? "dashboard"; } catch { return "dashboard"; } }); // Until plugins settle, a plugin view saved from last session hasn't had a // chance to register yet, so its absence doesn't mean it's really gone. const viewPending = !pluginsSettled && dashboardView !== "dashboard"; // Falls back to the dashboard when the view a plugin contributed is off. const isDashboardView = dashboardView === "dashboard" || (!viewPending && !secondaryView) || (!!secondaryView && dashboardView !== secondaryView.actionId); useEffect(() => { try { localStorage.setItem("dashboardView", dashboardView); } catch { /* ignore */ } }, [dashboardView]); const [editMode, setEditMode] = useState(false); const [dragState, setDragState] = useState(null); const [mainWidthPct, setMainWidthPct] = useState(() => { try { const saved = localStorage.getItem("dashboardTab.mainWidthPct"); if (saved) return Number(saved); } catch { /* ignore */ } return 68; }); useEffect(() => { try { localStorage.setItem("dashboardTab.slots", JSON.stringify(slots)); } catch { /* ignore */ } }, [slots]); useEffect(() => { try { localStorage.setItem("dashboardTab.mainWidthPct", String(mainWidthPct)); } catch { /* ignore */ } }, [mainWidthPct]); const [hosts, setHosts] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [uptimeFormatted, setUptimeFormatted] = useState(""); const [versionText, setVersionText] = useState(""); const [versionStatus, setVersionStatus] = useState< "up_to_date" | "requires_update" | "beta" | "unknown" >("up_to_date"); const [releaseUrl, setReleaseUrl] = useState(""); const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy"); const [credentialCount, setCredentialCount] = useState(0); const [activity, setActivity] = useState([]); const statusCheckHosts = hosts.filter(isStatusCheckEnabled); useEffect(() => { let mounted = true; const load = async () => { const raw = await getSSHHosts().catch(() => []); if (mounted) setHosts(raw.map(sshHostToHost)); }; load(); getUserInfo() .then((info) => { // A desktop on its own has one implicit user and nothing to // administer; linked, it is an admin when the server account is. setIsAdmin(isElectron() ? !!info.linked?.isAdmin : !!info.is_admin); }) .catch(() => {}); getUptime() .then((u) => setUptimeFormatted(u.formatted)) .catch(() => {}); getVersionInfo() .then((info) => { setVersionText(info.localVersion ?? ""); setVersionStatus(info.status ?? "unknown"); setReleaseUrl(releaseUrlFrom(info)); }) .catch(() => {}); getDatabaseHealth() .then((health) => { setDbHealth( health.status === "ok" || health.status === "healthy" ? "healthy" : "error", ); }) .catch(() => { setDbHealth("error"); }); getRecentActivity(50) .then(setActivity) .catch(() => {}); getCredentials() .then((res) => setCredentialCount( Array.isArray(res) ? res.length : Array.isArray(res?.credentials) ? res.credentials.length : 0, ), ) .catch(() => {}); if (!isVisible) { return () => { mounted = false; }; } const hostsInterval = setInterval(async () => { if (document.visibilityState === "hidden") return; const raw = await getSSHHosts().catch(() => []); if (mounted) setHosts(raw.map(sshHostToHost)); }, 30000); return () => { mounted = false; clearInterval(hostsInterval); }; }, [isVisible]); const handleClearActivity = async () => { try { await resetRecentActivity(); setActivity([]); } catch { /* ignore */ } }; const todayLabel = new Date().toLocaleDateString(i18n.language, { weekday: "long", month: "long", day: "numeric", year: "numeric", }); const bodyRef = useRef(null); const mainSlots = slots .filter((s) => s.panel === "main") .sort((a, b) => a.order - b.order); const sideSlots = slots .filter((s) => s.panel === "side") .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"), ...Object.fromEntries( registeredCards.map((card) => [card.id, t(card.titleKey)]), ), }; const onColumnDividerMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startPct = mainWidthPct; const onMove = (ev: MouseEvent) => { if (!bodyRef.current) return; const totalW = bodyRef.current.getBoundingClientRect().width; setMainWidthPct( Math.min( 85, Math.max(25, startPct + ((ev.clientX - startX) / totalW) * 100), ), ); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, [mainWidthPct], ); const handleDragStart = (slot: CardSlot) => setDragState({ key: slot.key, id: slot.id, sourcePanel: slot.panel, sourceOrder: slot.order, }); const handleDragOver = (e: React.DragEvent) => e.preventDefault(); const handleDrop = (targetPanel: PanelId, targetOrder: number) => { if (!dragState) return; setSlots((prev) => { const without = prev.filter((s) => s.key !== dragState.key); const panelSlots = without .filter((s) => s.panel === targetPanel) .sort((a, b) => a.order - b.order); const others = without.filter((s) => s.panel !== targetPanel); const insertIdx = panelSlots.findIndex((s) => s.order > targetOrder); const insertAt = insertIdx === -1 ? panelSlots.length : insertIdx; const newPanelSlots = [ ...panelSlots.slice(0, insertAt), { key: dragState.key, id: dragState.id, panel: targetPanel, order: 0, height: prev.find((s) => s.key === dragState.key)?.height ?? null, }, ...panelSlots.slice(insertAt), ].map((s, i) => ({ ...s, order: i })); return [...others, ...newPanelSlots]; }); setDragState(null); }; const handleRemove = (key: string) => setSlots((prev) => prev.filter((s) => s.key !== key)); const handleAdd = (id: DashboardCardId, panel: PanelId) => { setSlots((prev) => { const panelSlots = prev.filter((s) => s.panel === panel); const maxOrder = panelSlots.length > 0 ? Math.max(...panelSlots.map((s) => s.order)) + 1 : 0; const defaultHeight: number | null = getRegisteredDashboardCard(id)?.defaultHeight ?? (id === "host_status" || id === "recent_activity" ? null : 150); const key = `${id}_${Date.now()}`; return [ ...prev, { key, id, panel, order: maxOrder, height: defaultHeight }, ]; }); }; const handleHeightChange = (key: string, h: number) => setSlots((prev) => prev.map((s) => (s.key === key ? { ...s, height: h } : s)), ); const handleReset = () => { setSlots(DEFAULT_SLOTS); setMainWidthPct(68); setEditMode(false); try { localStorage.removeItem("dashboardTab.slots"); localStorage.removeItem("dashboardTab.mainWidthPct"); } catch { /* ignore */ } }; const columnProps = { hosts, uptimeFormatted, versionText, versionStatus, releaseUrl, dbHealth, credentialCount, activity, onClearActivity: handleClearActivity, onOpenSingletonTab, onOpenTab, cardLabels, isAdmin, statusLoading, isVisible, }; const isMobile = useIsMobile(); if (isMobile) { const allSlots = [...mainSlots, ...sideSlots]; return (

{t("dashboard.title")}

{todayLabel}

{allSlots.map((slot) => (
{slot.id === "stats_bar" && ( )} {slot.id === "counters_bar" && ( )} {slot.id === "quick_actions" && ( )} {slot.id === "host_status" && ( )} {slot.id === "recent_activity" && ( )} {!CORE_CARD_IDS.has(slot.id) && ( )}
))}
); } return (
{viewPending ? ( ) : (
{secondaryView && ( )}
)} {isDashboardView && !viewPending && (

{todayLabel}

)}
{t("dashboardTab.commandPalette")}
Shift + Shift
{isDashboardView && ( <> {editMode ? ( <> ) : ( )} )}
{viewPending ? (
) : !isDashboardView && secondaryView?.component ? (
) : ( <> {editMode && (
{t("dashboardTab.editModeInstructions")}
)}
{(hasSide || editMode) && (editMode ? ( ) : (
))} {(hasSide || editMode) && (
)}
)}
); }