feat: continued migration

This commit is contained in:
LukeGus
2026-05-12 02:12:02 -05:00
parent 0efcb7de7e
commit 835b98a102
19 changed files with 1836 additions and 682 deletions
+57 -27
View File
@@ -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 (
<div
key={card.id}
className="flex flex-col"
style={
card.height
? { height: card.height, flexShrink: 0 }
: { flex: 1, minHeight: 280 }
}
>
<div className="flex-1 min-h-0">{renderCard(card)}</div>
<div
className="flex-shrink-0 h-2 my-0.5 flex items-center justify-center cursor-row-resize group"
onMouseDown={(e) =>
handleCardHeightMouseDown(e, card.id, currentHeight)
}
>
<div className="w-16 h-0.5 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</div>
);
};
return (
<>
<div
className="flex flex-col gap-4 overflow-auto min-h-0"
className="flex flex-col gap-2 overflow-auto min-h-0"
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
>
{mainCards.map((card) => (
<div
key={card.id}
style={
card.height
? { height: card.height, flexShrink: 0 }
: { flex: 1, minHeight: 280 }
}
>
{renderCard(card)}
</div>
))}
{mainCards.map((card) => renderCardWithHandle(card))}
</div>
<div
@@ -816,21 +857,10 @@ export function Dashboard({
</div>
<div
className="flex flex-col gap-4 overflow-auto min-h-0"
className="flex flex-col gap-2 overflow-auto min-h-0"
style={{ flex: 1, minWidth: 0 }}
>
{sideCards.map((card) => (
<div
key={card.id}
style={
card.height
? { height: card.height, flexShrink: 0 }
: { flex: 1, minHeight: 280 }
}
>
{renderCard(card)}
</div>
))}
{sideCards.map((card) => renderCardWithHandle(card))}
</div>
</>
);
+201 -99
View File
@@ -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<DashboardCardId, { label: string }> = {
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 (
<Card className="grid grid-cols-4 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
<div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Version
{t("dashboard.version")}
</span>
<span className="text-xl font-bold text-accent-brand leading-none">
v2.2.0
{versionText || "—"}
</span>
<span className="text-[10px] bg-accent-brand/20 text-accent-brand px-1.5 py-0.5 w-fit font-semibold leading-none">
STABLE
<span
className={`text-[10px] px-1.5 py-0.5 w-fit font-semibold leading-none ${statusColor}`}
>
{statusLabel}
</span>
</div>
<div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Uptime
{t("dashboard.uptime")}
</span>
<span className="text-xl font-bold leading-none">
{uptimeFormatted || "—"}
@@ -194,15 +209,19 @@ function StatsBarCard({
</div>
<div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Database
{t("dashboard.database")}
</span>
<span className="text-xl font-bold text-accent-brand leading-none">
Healthy
<span
className={`text-xl font-bold leading-none ${dbHealth === "healthy" ? "text-accent-brand" : "text-red-400"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</span>
</div>
<div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Hosts Online
{t("dashboardTab.hostsOnline")}
</span>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold leading-none">{online}</span>
@@ -224,27 +243,28 @@ function CountersBarCard({
credentialCount: number;
activeTunnelCount: number;
}) {
const { t } = useTranslation();
return (
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
<div className="flex items-center gap-2.5 px-4 py-2.5">
<Server className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{hosts.length}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Total Hosts
{t("dashboard.totalHosts")}
</span>
</div>
<div className="flex items-center gap-2.5 px-4 py-2.5">
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{credentialCount}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Credentials
{t("dashboard.totalCredentials")}
</span>
</div>
<div className="flex items-center gap-2.5 px-4 py-2.5">
<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">
Active Tunnels
{t("dashboardTab.activeTunnels")}
</span>
</div>
</Card>
@@ -256,12 +276,13 @@ function QuickActionsCard({
}: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) {
const { t } = useTranslation();
return (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<Zap className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Quick Actions
{t("dashboard.quickActions")}
</span>
</div>
<div className="flex flex-1 min-h-0">
@@ -276,9 +297,11 @@ function QuickActionsCard({
<Plus className="size-3 text-accent-brand" />
</div>
<div className="flex flex-col items-start text-left">
<span className="text-xs font-semibold">Add Host</span>
<span className="text-xs font-semibold">
{t("dashboard.addHost")}
</span>
<span className="text-[10px] text-muted-foreground">
Register a new server
{t("dashboardTab.registerNewServer")}
</span>
</div>
</button>
@@ -292,9 +315,11 @@ function QuickActionsCard({
<KeyRound className="size-3 text-accent-brand" />
</div>
<div className="flex flex-col items-start text-left">
<span className="text-xs font-semibold">Add Credential</span>
<span className="text-xs font-semibold">
{t("dashboard.addCredential")}
</span>
<span className="text-[10px] text-muted-foreground">
Store SSH keys or passwords
{t("dashboardTab.storeSshKeysOrPasswords")}
</span>
</div>
</button>
@@ -308,9 +333,11 @@ function QuickActionsCard({
<Settings className="size-3 text-accent-brand" />
</div>
<div className="flex flex-col items-start text-left">
<span className="text-xs font-semibold">Admin Settings</span>
<span className="text-xs font-semibold">
{t("dashboard.adminSettings")}
</span>
<span className="text-[10px] text-muted-foreground">
Manage users and roles
{t("dashboardTab.manageUsersAndRoles")}
</span>
</div>
</button>
@@ -322,9 +349,11 @@ function QuickActionsCard({
<User className="size-3 text-accent-brand" />
</div>
<div className="flex flex-col items-start text-left">
<span className="text-xs font-semibold">User Profile</span>
<span className="text-xs font-semibold">
{t("dashboard.userProfile")}
</span>
<span className="text-[10px] text-muted-foreground">
Manage your account
{t("dashboardTab.manageYourAccount")}
</span>
</div>
</button>
@@ -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 (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
@@ -348,17 +378,17 @@ function HostStatusCard({
<div className="flex items-center gap-2">
<Database className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Host Status
{t("dashboardTab.hostStatus")}
</span>
</div>
<span className="text-xs text-muted-foreground">
{online}/{hosts.length} online
{online}/{hosts.length} {t("dashboardTab.onlineLower")}
</span>
</div>
<div className="flex flex-col overflow-auto flex-1">
{hosts.length === 0 && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-8">
No hosts configured
{t("dashboardTab.noHostsConfigured")}
</div>
)}
{hosts.map((host, i) => (
@@ -384,7 +414,7 @@ function HostStatusCard({
<div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
CPU
{t("dashboard.cpu")}
</span>
<span className="text-[10px] font-bold text-accent-brand">
{host.cpu ?? 0}%
@@ -400,7 +430,7 @@ function HostStatusCard({
<div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
RAM
{t("dashboard.ram")}
</span>
<span className="text-[10px] font-bold text-accent-brand">
{host.ram ?? 0}%
@@ -427,7 +457,9 @@ function HostStatusCard({
<span
className={`text-[10px] px-2 py-0.5 font-semibold border ${host.online ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-border text-muted-foreground"}`}
>
{host.online ? "ONLINE" : "OFFLINE"}
{host.online
? t("dashboardTab.online")
: t("dashboardTab.offline")}
</span>
</div>
</div>
@@ -448,6 +480,7 @@ function RecentActivityCard({
onOpenTab: (host: Host, type: TabType) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
terminal: <Terminal className="size-2.5" />,
file_manager: <Server className="size-2.5" />,
@@ -468,12 +501,24 @@ function RecentActivityCard({
vnc: "vnc",
telnet: "telnet",
};
const typeLabel: Record<RecentActivityItem["type"], string> = {
terminal: t("networkGraph.terminal"),
file_manager: t("networkGraph.fileManager"),
server_stats: t("networkGraph.serverStats"),
tunnel: t("networkGraph.tunnel"),
docker: t("networkGraph.docker"),
rdp: "RDP",
vnc: "VNC",
telnet: "Telnet",
};
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 (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
@@ -481,7 +526,7 @@ function RecentActivityCard({
<div className="flex items-center gap-2">
<Activity className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Recent Activity
{t("dashboard.recentActivity")}
</span>
</div>
<Button
@@ -490,13 +535,13 @@ function RecentActivityCard({
className="text-xs text-accent-brand h-auto py-0.5 px-2"
onClick={onClear}
>
Clear
{t("dashboardTab.clear")}
</Button>
</div>
<div className="flex flex-col overflow-auto flex-1">
{activity.length === 0 && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-8">
No recent activity
{t("dashboard.noRecentActivity")}
</div>
)}
{activity.map((item) => {
@@ -519,9 +564,7 @@ function RecentActivityCard({
</span>
<div className="flex items-center gap-1 text-muted-foreground">
{typeIcon[item.type]}
<span className="text-[10px] capitalize">
{item.type.replace("_", " ")}
</span>
<span className="text-[10px]">{typeLabel[item.type]}</span>
</div>
</div>
</div>
@@ -537,6 +580,7 @@ function RecentActivityCard({
}
function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
const { t } = useTranslation();
const cyRef = useRef<any>(null);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
@@ -639,12 +683,12 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
<div className="flex items-center gap-2">
<Network className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Network Graph
{t("dashboard.networkGraph")}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{hosts.length} nodes
{t("dashboardTab.nodes", { count: hosts.length })}
</span>
<Button
variant="ghost"
@@ -673,11 +717,11 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
</div>
<button className="flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left w-full">
<Terminal className="size-3" />
Terminal
{t("networkGraph.terminal")}
</button>
<button className="flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left w-full">
<Server className="size-3" />
Server Stats
{t("networkGraph.serverStats")}
</button>
</div>
)}
@@ -695,20 +739,20 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
/>
) : (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 h-full">
No hosts to display
{t("dashboardTab.noHostsToDisplay")}
</div>
)}
<div className="absolute bottom-2 left-3 flex items-center gap-3 pointer-events-none">
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-accent-brand inline-block" />
<span className="text-[10px] text-muted-foreground font-mono">
Online
{t("dashboardTab.onlineLower")}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-muted-foreground/50 inline-block" />
<span className="text-[10px] text-muted-foreground font-mono">
Offline
{t("dashboardTab.offlineLower")}
</span>
</div>
</div>
@@ -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 (
<div
@@ -815,37 +856,35 @@ function CardItem({
<div className="flex-1 min-h-0 overflow-hidden">
{slot.id === "stats_bar" && (
<StatsBarCard
hosts={cardProps.hosts}
uptimeFormatted={cardProps.uptimeFormatted}
hosts={hosts}
uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
dbHealth={dbHealth}
/>
)}
{slot.id === "counters_bar" && (
<CountersBarCard
hosts={cardProps.hosts}
credentialCount={cardProps.credentialCount}
activeTunnelCount={cardProps.activeTunnelCount}
hosts={hosts}
credentialCount={credentialCount}
activeTunnelCount={activeTunnelCount}
/>
)}
{slot.id === "quick_actions" && (
<QuickActionsCard onOpenSingletonTab={cardProps.onOpenSingletonTab} />
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
)}
{slot.id === "host_status" && (
<HostStatusCard
hosts={cardProps.hosts}
onOpenTab={cardProps.onOpenTab}
/>
<HostStatusCard hosts={hosts} onOpenTab={onOpenTab} />
)}
{slot.id === "recent_activity" && (
<RecentActivityCard
activity={activity}
hosts={cardProps.hosts}
onOpenTab={cardProps.onOpenTab}
hosts={hosts}
onOpenTab={onOpenTab}
onClear={onClearActivity}
/>
)}
{slot.id === "network_graph" && (
<NetworkGraphCard hosts={cardProps.hosts} />
)}
{slot.id === "network_graph" && <NetworkGraphCard hosts={hosts} />}
</div>
{editMode && !isFlex && (
<div
@@ -898,16 +937,19 @@ function DropZone({
function AddCardTray({
activeIds,
onAdd,
cardLabels,
}: {
activeIds: DashboardCardId[];
onAdd: (id: DashboardCardId) => void;
cardLabels: Record<DashboardCardId, string>;
}) {
const { t } = useTranslation();
const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id));
if (available.length === 0) return null;
return (
<div className="flex items-center gap-2 px-1 py-2 flex-wrap shrink-0">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold shrink-0">
Add:
{t("dashboardTab.add")}
</span>
{available.map((card) => (
<button
@@ -916,7 +958,7 @@ function AddCardTray({
className="flex items-center gap-1.5 px-2.5 py-1 border border-dashed border-border text-xs text-muted-foreground hover:text-foreground hover:border-accent-brand/60 hover:bg-accent-brand/5 transition-colors"
>
<Plus className="size-3 text-accent-brand" />
{CARD_META[card.id].label}
{cardLabels[card.id]}
</button>
))}
</div>
@@ -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<DashboardCardId, string>;
};
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 && (
<AddCardTray activeIds={allIds} onAdd={(id) => onAdd(id, panel)} />
<AddCardTray
activeIds={allIds}
onAdd={(id) => onAdd(id, panel)}
cardLabels={cardLabels}
/>
)}
{sorted.length === 0 && !editMode && (
<div className="flex-1 flex items-center justify-center text-muted-foreground/20 text-xs border border-dashed border-border/30">
Empty
{t("dashboardTab.empty")}
</div>
)}
</div>
@@ -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<CardSlot[]>(DEFAULT_SLOTS);
const [editMode, setEditMode] = useState(false);
const [dragState, setDragState] = useState<DragState>(null);
@@ -1068,6 +1127,11 @@ export function DashboardTab({
const [hosts, setHosts] = useState<Host[]>([]);
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<RecentActivityItem[]>([]);
@@ -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<DashboardCardId, string> = {
stats_bar: t("dashboard.serverOverview"),
counters_bar: t("dashboard.serverStats"),
quick_actions: t("dashboard.quickActions"),
host_status: t("dashboardTab.hostStatus"),
recent_activity: t("dashboard.recentActivity"),
network_graph: t("dashboard.networkGraph"),
};
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({
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3 pt-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-4 py-3 shrink-0 gap-0">
<div>
<h1 className="text-base font-bold leading-tight">Dashboard</h1>
<h1 className="text-base font-bold leading-tight">
{t("dashboard.title")}
</h1>
<p className="text-xs text-muted-foreground">{todayLabel}</p>
</div>
<div className="flex items-center gap-1">
@@ -1243,7 +1339,7 @@ export function DashboardTab({
target="_blank"
rel="noreferrer"
>
GitHub
{t("dashboard.github")}
</a>
</Button>
<Button
@@ -1257,7 +1353,7 @@ export function DashboardTab({
target="_blank"
rel="noreferrer"
>
Support
{t("dashboard.support")}
</a>
</Button>
<Button
@@ -1271,7 +1367,7 @@ export function DashboardTab({
target="_blank"
rel="noreferrer"
>
Discord
{t("dashboard.discord")}
</a>
</Button>
</div>
@@ -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" && (
<StatsBarCard hosts={hosts} uptimeFormatted={uptimeFormatted} />
<StatsBarCard
hosts={hosts}
uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
dbHealth={dbHealth}
/>
)}
{slot.id === "counters_bar" && (
<CountersBarCard
@@ -1319,13 +1421,15 @@ export function DashboardTab({
<div className="flex flex-col w-full h-full min-h-0 overflow-hidden">
<Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0">
<div>
<h1 className="text-lg font-bold leading-tight">Dashboard</h1>
<h1 className="text-lg font-bold leading-tight">
{t("dashboard.title")}
</h1>
<p className="text-xs text-muted-foreground">{todayLabel}</p>
</div>
<div className="flex items-center gap-1">
<div className="hidden sm:flex items-center gap-2 mr-2 bg-muted/50 px-2.5 py-1 rounded-sm border border-border">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Command Palette
{t("dashboardTab.commandPalette")}
</span>
<div className="flex items-center gap-1">
<Kbd className="h-5 px-1.5 bg-background text-[10px]">Shift</Kbd>
@@ -1344,7 +1448,7 @@ export function DashboardTab({
target="_blank"
rel="noreferrer"
>
GitHub
{t("dashboard.github")}
</a>
</Button>
<Button
@@ -1358,7 +1462,7 @@ export function DashboardTab({
target="_blank"
rel="noreferrer"
>
Support
{t("dashboard.support")}
</a>
</Button>
<Button
@@ -1372,7 +1476,7 @@ export function DashboardTab({
target="_blank"
rel="noreferrer"
>
Discord
{t("dashboard.discord")}
</a>
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
@@ -1384,14 +1488,14 @@ export function DashboardTab({
className="text-xs text-muted-foreground"
onClick={handleReset}
>
Reset
{t("dashboard.reset")}
</Button>
<Button
size="sm"
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white"
onClick={() => setEditMode(false)}
>
Done
{t("dashboardTab.done")}
</Button>
</>
) : (
@@ -1399,7 +1503,7 @@ export function DashboardTab({
variant="ghost"
size="icon"
onClick={() => setEditMode(true)}
title="Customize Dashboard"
title={t("dashboard.customizeLayout")}
>
<LayoutDashboard className="size-4 text-accent-brand" />
</Button>
@@ -1411,9 +1515,7 @@ export function DashboardTab({
<div className="mx-5 mt-4 px-4 py-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 shrink-0 flex items-center gap-2">
<LayoutDashboard className="size-3.5 text-accent-brand shrink-0" />
<span className="text-xs text-accent-brand font-semibold">
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")}
</span>
</div>
)}
+38 -9
View File
@@ -1594,15 +1594,44 @@ export function NetworkGraphCard({
<NetworkIcon className="mr-3" />
{t("dashboard.networkGraph")}
</p>
<Button
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="flex items-center gap-2 h-8"
title={t("common.openInNewTab")}
>
<ArrowUp className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => cyRef.current?.zoom(cyRef.current.zoom() * 1.2)}
title={t("networkGraph.zoomIn")}
className="h-8 w-8 p-0"
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => cyRef.current?.zoom(cyRef.current.zoom() / 1.2)}
title={t("networkGraph.zoomOut")}
className="h-8 w-8 p-0"
>
<ZoomOut className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => cyRef.current?.fit()}
title={t("networkGraph.resetView")}
className="h-8 w-8 p-0"
>
<RotateCw className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="h-8 w-8 p-0"
title={t("common.openInNewTab")}
>
<ArrowUp className="w-4 h-4" />
</Button>
</div>
</div>
<AlertDialog open={!!error} onOpenChange={() => setError(null)}>
+23 -1
View File
@@ -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({
) : (
<Terminal size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">{item.hostName}</p>
<div className="flex flex-col items-start min-w-0 ml-2">
<p className="truncate font-semibold leading-none">
{item.hostName}
</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
{formatRelativeTime(item.timestamp, t)}
</p>
</div>
</Button>
))
)}
+12 -6
View File
@@ -56,22 +56,28 @@ export function ServerOverviewCard({
<p className="leading-none text-muted-foreground">
{versionText}
</p>
{!updateCheckDisabled && (
{versionStatus === "beta" ? (
<Button
variant="outline"
size="sm"
className="ml-2 text-sm border-1 border-edge text-blue-400"
>
{t("dashboard.beta")}
</Button>
) : !updateCheckDisabled ? (
<>
<Button
variant="outline"
size="sm"
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : versionStatus === "beta" ? "text-blue-400" : "text-yellow-400"}`}
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
>
{versionStatus === "up_to_date"
? t("dashboard.upToDate")
: versionStatus === "beta"
? t("dashboard.beta")
: t("dashboard.updateAvailable")}
: t("dashboard.updateAvailable")}
</Button>
<UpdateLog loggedIn={loggedIn} />
</>
)}
) : null}
</div>
</div>
+16 -14
View File
@@ -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 (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
@@ -38,12 +42,12 @@ export function ServerStatsCard({
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span>
</div>
) : serverStats.length === 0 ? (
) : visibleStats.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noServerData")}
</p>
) : (
serverStats.map((server) => (
visibleStats.map((server) => (
<Button
key={server.id}
variant="outline"
@@ -56,18 +60,16 @@ export function ServerStatsCard({
<p className="truncate ml-2 font-semibold">{server.name}</p>
</div>
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
<span>
{t("dashboard.cpu")}:{" "}
{server.cpu !== null
? `${server.cpu}%`
: t("dashboard.notAvailable")}
</span>
<span>
{t("dashboard.ram")}:{" "}
{server.ram !== null
? `${server.ram}%`
: t("dashboard.notAvailable")}
</span>
{server.cpu !== null && (
<span>
{t("dashboard.cpu")}: {server.cpu.toFixed(1)}%
</span>
)}
{server.ram !== null && (
<span>
{t("dashboard.ram")}: {server.ram.toFixed(1)}%
</span>
)}
</div>
</div>
</Button>
@@ -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<DashboardLayout | null>(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) {
+126 -48
View File
@@ -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 (
<div className="h-full w-full flex flex-col bg-background relative">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
</div>
<ConnectionLog
isConnecting={isLoading || isReconnecting}
isConnected={false}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background relative">
<div
@@ -2823,10 +2905,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}}
onSubmit={handleSudoPasswordSubmit}
/>
<SimpleLoader
visible={(isReconnecting || isLoading) && !isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
<ConnectionLog
isConnecting={isReconnecting || isLoading}
isConnected={!!sshSessionId}
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import {
Download,
@@ -143,14 +143,6 @@ export function FileManagerContextMenu({
setIsMounted(true);
const adjustPosition = () => {
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({
>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div>
<span className="flex-1 truncate">{item.label}</span>
<span className="flex-1 whitespace-normal break-words">
{item.label}
</span>
</div>
{item.shortcut && (
<div className="ml-2 flex-shrink-0">
@@ -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;
@@ -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 (
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
<CommandHistoryProvider>
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
/>
</DraggableWindow>
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
</CommandHistoryProvider>
);
}
+26 -1
View File
@@ -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",
+1 -1
View File
@@ -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 });
File diff suppressed because it is too large Load Diff
+67 -39
View File
@@ -18,13 +18,42 @@ export function isFolder(item: Host | HostFolder): item is HostFolder {
return "children" in item;
}
const SSH_ACTIONS: { type: TabType; icon: typeof Terminal; label: string }[] = [
{ type: "terminal", icon: Terminal, label: "Terminal" },
{ type: "stats", icon: Server, label: "Stats" },
{ type: "files", icon: FolderSearch, label: "Files" },
{ type: "docker", icon: Box, label: "Docker" },
{ type: "tunnel", icon: Network, label: "Tunnel" },
];
function getSshActions(
host: Host,
): { type: TabType; icon: typeof Terminal; label: string }[] {
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
return [
host.enableTerminal && {
type: "terminal" as TabType,
icon: Terminal,
label: "Terminal",
},
host.enableFileManager && {
type: "files" as TabType,
icon: FolderSearch,
label: "Files",
},
host.enableDocker && {
type: "docker" as TabType,
icon: Box,
label: "Docker",
},
host.enableTunnel && {
type: "tunnel" as TabType,
icon: Network,
label: "Tunnel",
},
metricsEnabled && {
type: "stats" as TabType,
icon: Server,
label: "Stats",
},
].filter(Boolean) as {
type: TabType;
icon: typeof Terminal;
label: string;
}[];
}
function hostMatchesQuery(host: Host, query: string) {
return (
@@ -142,43 +171,42 @@ export function HostItem({
opacity: hovered ? 1 : 0,
}}
>
{host.online &&
(host.cpu !== undefined || host.ram !== undefined) && (
<div className="flex items-center gap-3 pl-3">
{host.cpu !== undefined && (
<div className="flex items-center gap-1">
<Cpu className="size-2.5 shrink-0 text-muted-foreground/30" />
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
<span className="text-[9px] tabular-nums text-muted-foreground/40">
{host.cpu}%
</span>
{host.online && (host.cpu != null || host.ram != null) && (
<div className="flex items-center gap-3 pl-3">
{host.cpu != null && (
<div className="flex items-center gap-1">
<Cpu className="size-2.5 shrink-0 text-muted-foreground/30" />
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
)}
{host.ram !== undefined && (
<div className="flex items-center gap-1">
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/30" />
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
<span className="text-[9px] tabular-nums text-muted-foreground/40">
{host.ram}%
</span>
<span className="text-[9px] tabular-nums text-muted-foreground/40">
{host.cpu}%
</span>
</div>
)}
{host.ram != null && (
<div className="flex items-center gap-1">
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/30" />
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
)}
</div>
)}
<span className="text-[9px] tabular-nums text-muted-foreground/40">
{host.ram}%
</span>
</div>
)}
</div>
)}
<div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1">
{host.enableSsh &&
SSH_ACTIONS.map(({ type, icon: Icon, label }) => (
getSshActions(host).map(({ type, icon: Icon, label }) => (
<button
key={type}
title={label}
+1 -1
View File
@@ -158,7 +158,7 @@ export function ConnectionLog({
{logs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{isConnecting
? t("terminal.connectionLogConnecting")
? t("terminal.connectionLogWaiting")
: t("terminal.connectionLogEmpty")}
</div>
) : (