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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "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", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+6 -2
View File
@@ -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)) { if (!cards || !Array.isArray(cards)) {
return res.status(400).json({ return res.status(400).json({
@@ -478,7 +478,11 @@ app.post("/dashboard/preferences", async (req, res) => {
}); });
} }
const layout = JSON.stringify({ cards }); const layoutObj: Record<string, unknown> = { cards };
if (typeof mainWidthPct === "number") {
layoutObj.mainWidthPct = mainWidthPct;
}
const layout = JSON.stringify(layoutObj);
const existing = await getDb() const existing = await getDb()
.select() .select()
+2 -2
View File
@@ -6,8 +6,8 @@ export type Host = {
port: number; port: number;
folder: string; folder: string;
online: boolean; online: boolean;
cpu: number; cpu: number | null;
ram: number; ram: number | null;
lastAccess: string; lastAccess: string;
tags?: string[]; tags?: string[];
authType: "password" | "key" | "credential" | "none" | "opkssh"; authType: "password" | "key" | "credential" | "none" | "opkssh";
+57 -27
View File
@@ -129,6 +129,31 @@ export function Dashboard({
[mainWidthPct, layout, updateLayout], [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"; let sidebarState: "expanded" | "collapsed" = "expanded";
try { try {
const sidebar = useSidebar(); const sidebar = useSidebar();
@@ -385,7 +410,7 @@ export function Dashboard({
name: string; name: string;
cpu: number | null; cpu: number | null;
ram: number | null; ram: number | null;
} => server !== null && server.cpu !== null && server.ram !== null, } => server !== null,
); );
setServerStats(validServerStats); setServerStats(validServerStats);
setServerStatsLoading(false); setServerStatsLoading(false);
@@ -788,24 +813,40 @@ export function Dashboard({
return null; 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 ( return (
<> <>
<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={{ width: `${mainWidthPct}%`, minWidth: 0 }} style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
> >
{mainCards.map((card) => ( {mainCards.map((card) => renderCardWithHandle(card))}
<div
key={card.id}
style={
card.height
? { height: card.height, flexShrink: 0 }
: { flex: 1, minHeight: 280 }
}
>
{renderCard(card)}
</div>
))}
</div> </div>
<div <div
@@ -816,21 +857,10 @@ export function Dashboard({
</div> </div>
<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 }} style={{ flex: 1, minWidth: 0 }}
> >
{sideCards.map((card) => ( {sideCards.map((card) => renderCardWithHandle(card))}
<div
key={card.id}
style={
card.height
? { height: card.height, flexShrink: 0 }
: { flex: 1, minHeight: 280 }
}
>
{renderCard(card)}
</div>
))}
</div> </div>
</> </>
); );
+201 -99
View File
@@ -27,12 +27,15 @@ import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
import { import {
getSSHHosts, getSSHHosts,
getUptime, getUptime,
getVersionInfo,
getDatabaseHealth,
getRecentActivity, getRecentActivity,
getTunnelStatuses, getTunnelStatuses,
getCredentials, getCredentials,
resetRecentActivity, resetRecentActivity,
} from "@/main-axios"; } from "@/main-axios";
import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios"; import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
import { useTranslation } from "react-i18next";
function sshHostToHost(h: SSHHostWithStatus): Host { function sshHostToHost(h: SSHHostWithStatus): Host {
return { return {
@@ -110,15 +113,6 @@ const DEFAULT_SLOTS: CardSlot[] = [
{ id: "recent_activity", panel: "side", order: 0, height: null }, { 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 ────────────────────────────────────────────────────────── // ─── useColumnResize ──────────────────────────────────────────────────────────
function useColumnResize( function useColumnResize(
@@ -166,27 +160,48 @@ function useColumnResize(
function StatsBarCard({ function StatsBarCard({
hosts, hosts,
uptimeFormatted, uptimeFormatted,
versionText,
versionStatus,
dbHealth,
}: { }: {
hosts: Host[]; hosts: Host[];
uptimeFormatted: string; 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 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 ( return (
<Card className="grid grid-cols-4 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0"> <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"> <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"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Version {t("dashboard.version")}
</span> </span>
<span className="text-xl font-bold text-accent-brand leading-none"> <span className="text-xl font-bold text-accent-brand leading-none">
v2.2.0 {versionText || "—"}
</span> </span>
<span className="text-[10px] bg-accent-brand/20 text-accent-brand px-1.5 py-0.5 w-fit font-semibold leading-none"> <span
STABLE className={`text-[10px] px-1.5 py-0.5 w-fit font-semibold leading-none ${statusColor}`}
>
{statusLabel}
</span> </span>
</div> </div>
<div className="flex flex-col justify-center px-4 py-2 gap-1"> <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"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Uptime {t("dashboard.uptime")}
</span> </span>
<span className="text-xl font-bold leading-none"> <span className="text-xl font-bold leading-none">
{uptimeFormatted || "—"} {uptimeFormatted || "—"}
@@ -194,15 +209,19 @@ function StatsBarCard({
</div> </div>
<div className="flex flex-col justify-center px-4 py-2 gap-1"> <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"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Database {t("dashboard.database")}
</span> </span>
<span className="text-xl font-bold text-accent-brand leading-none"> <span
Healthy className={`text-xl font-bold leading-none ${dbHealth === "healthy" ? "text-accent-brand" : "text-red-400"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</span> </span>
</div> </div>
<div className="flex flex-col justify-center px-4 py-2 gap-1"> <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"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Hosts Online {t("dashboardTab.hostsOnline")}
</span> </span>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-xl font-bold leading-none">{online}</span> <span className="text-xl font-bold leading-none">{online}</span>
@@ -224,27 +243,28 @@ function CountersBarCard({
credentialCount: number; credentialCount: number;
activeTunnelCount: number; activeTunnelCount: number;
}) { }) {
const { t } = useTranslation();
return ( return (
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0"> <Card className="grid grid-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"> <div className="flex items-center gap-2.5 px-4 py-2.5">
<Server className="size-3.5 text-muted-foreground shrink-0" /> <Server className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{hosts.length}</span> <span className="text-base font-bold">{hosts.length}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Total Hosts {t("dashboard.totalHosts")}
</span> </span>
</div> </div>
<div className="flex items-center gap-2.5 px-4 py-2.5"> <div className="flex items-center gap-2.5 px-4 py-2.5">
<KeyRound className="size-3.5 text-muted-foreground shrink-0" /> <KeyRound className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{credentialCount}</span> <span className="text-base font-bold">{credentialCount}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Credentials {t("dashboard.totalCredentials")}
</span> </span>
</div> </div>
<div className="flex items-center gap-2.5 px-4 py-2.5"> <div className="flex items-center gap-2.5 px-4 py-2.5">
<Network className="size-3.5 text-muted-foreground shrink-0" /> <Network className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{activeTunnelCount}</span> <span className="text-base font-bold">{activeTunnelCount}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Active Tunnels {t("dashboardTab.activeTunnels")}
</span> </span>
</div> </div>
</Card> </Card>
@@ -256,12 +276,13 @@ function QuickActionsCard({
}: { }: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0"> <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"> <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" /> <Zap className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Quick Actions {t("dashboard.quickActions")}
</span> </span>
</div> </div>
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
@@ -276,9 +297,11 @@ function QuickActionsCard({
<Plus className="size-3 text-accent-brand" /> <Plus className="size-3 text-accent-brand" />
</div> </div>
<div className="flex flex-col items-start text-left"> <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"> <span className="text-[10px] text-muted-foreground">
Register a new server {t("dashboardTab.registerNewServer")}
</span> </span>
</div> </div>
</button> </button>
@@ -292,9 +315,11 @@ function QuickActionsCard({
<KeyRound className="size-3 text-accent-brand" /> <KeyRound className="size-3 text-accent-brand" />
</div> </div>
<div className="flex flex-col items-start text-left"> <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"> <span className="text-[10px] text-muted-foreground">
Store SSH keys or passwords {t("dashboardTab.storeSshKeysOrPasswords")}
</span> </span>
</div> </div>
</button> </button>
@@ -308,9 +333,11 @@ function QuickActionsCard({
<Settings className="size-3 text-accent-brand" /> <Settings className="size-3 text-accent-brand" />
</div> </div>
<div className="flex flex-col items-start text-left"> <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"> <span className="text-[10px] text-muted-foreground">
Manage users and roles {t("dashboardTab.manageUsersAndRoles")}
</span> </span>
</div> </div>
</button> </button>
@@ -322,9 +349,11 @@ function QuickActionsCard({
<User className="size-3 text-accent-brand" /> <User className="size-3 text-accent-brand" />
</div> </div>
<div className="flex flex-col items-start text-left"> <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"> <span className="text-[10px] text-muted-foreground">
Manage your account {t("dashboardTab.manageYourAccount")}
</span> </span>
</div> </div>
</button> </button>
@@ -341,6 +370,7 @@ function HostStatusCard({
hosts: Host[]; hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
}) { }) {
const { t } = useTranslation();
const online = hosts.filter((h) => h.online).length; const online = hosts.filter((h) => h.online).length;
return ( return (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0"> <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"> <div className="flex items-center gap-2">
<Database className="size-3.5 text-muted-foreground" /> <Database className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Host Status {t("dashboardTab.hostStatus")}
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{online}/{hosts.length} online {online}/{hosts.length} {t("dashboardTab.onlineLower")}
</span> </span>
</div> </div>
<div className="flex flex-col overflow-auto flex-1"> <div className="flex flex-col overflow-auto flex-1">
{hosts.length === 0 && ( {hosts.length === 0 && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-8"> <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-8">
No hosts configured {t("dashboardTab.noHostsConfigured")}
</div> </div>
)} )}
{hosts.map((host, i) => ( {hosts.map((host, i) => (
@@ -384,7 +414,7 @@ function HostStatusCard({
<div className="flex flex-col gap-0.5 w-16"> <div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
CPU {t("dashboard.cpu")}
</span> </span>
<span className="text-[10px] font-bold text-accent-brand"> <span className="text-[10px] font-bold text-accent-brand">
{host.cpu ?? 0}% {host.cpu ?? 0}%
@@ -400,7 +430,7 @@ function HostStatusCard({
<div className="flex flex-col gap-0.5 w-16"> <div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
RAM {t("dashboard.ram")}
</span> </span>
<span className="text-[10px] font-bold text-accent-brand"> <span className="text-[10px] font-bold text-accent-brand">
{host.ram ?? 0}% {host.ram ?? 0}%
@@ -427,7 +457,9 @@ function HostStatusCard({
<span <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"}`} 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> </span>
</div> </div>
</div> </div>
@@ -448,6 +480,7 @@ function RecentActivityCard({
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
onClear: () => void; onClear: () => void;
}) { }) {
const { t } = useTranslation();
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = { const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
terminal: <Terminal className="size-2.5" />, terminal: <Terminal className="size-2.5" />,
file_manager: <Server className="size-2.5" />, file_manager: <Server className="size-2.5" />,
@@ -468,12 +501,24 @@ function RecentActivityCard({
vnc: "vnc", vnc: "vnc",
telnet: "telnet", 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) { function formatTime(ts: string) {
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); const diffMs = Date.now() - new Date(ts).getTime();
if (diff < 60) return `${diff}s ago`; if (diffMs < 0) return t("dashboard.justNow");
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; const diff = Math.floor(diffMs / 1000);
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 60) return t("dashboard.justNow");
return `${Math.floor(diff / 86400)}d ago`; 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 ( return (
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0"> <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"> <div className="flex items-center gap-2">
<Activity className="size-3.5 text-muted-foreground" /> <Activity className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Recent Activity {t("dashboard.recentActivity")}
</span> </span>
</div> </div>
<Button <Button
@@ -490,13 +535,13 @@ function RecentActivityCard({
className="text-xs text-accent-brand h-auto py-0.5 px-2" className="text-xs text-accent-brand h-auto py-0.5 px-2"
onClick={onClear} onClick={onClear}
> >
Clear {t("dashboardTab.clear")}
</Button> </Button>
</div> </div>
<div className="flex flex-col overflow-auto flex-1"> <div className="flex flex-col overflow-auto flex-1">
{activity.length === 0 && ( {activity.length === 0 && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-8"> <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-8">
No recent activity {t("dashboard.noRecentActivity")}
</div> </div>
)} )}
{activity.map((item) => { {activity.map((item) => {
@@ -519,9 +564,7 @@ function RecentActivityCard({
</span> </span>
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
{typeIcon[item.type]} {typeIcon[item.type]}
<span className="text-[10px] capitalize"> <span className="text-[10px]">{typeLabel[item.type]}</span>
{item.type.replace("_", " ")}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -537,6 +580,7 @@ function RecentActivityCard({
} }
function NetworkGraphCard({ hosts }: { hosts: Host[] }) { function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
const { t } = useTranslation();
const cyRef = useRef<any>(null); const cyRef = useRef<any>(null);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
visible: boolean; visible: boolean;
@@ -639,12 +683,12 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Network className="size-3.5 text-muted-foreground" /> <Network className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
Network Graph {t("dashboard.networkGraph")}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{hosts.length} nodes {t("dashboardTab.nodes", { count: hosts.length })}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
@@ -673,11 +717,11 @@ function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
</div> </div>
<button className="flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left w-full"> <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 className="size-3" />
Terminal {t("networkGraph.terminal")}
</button> </button>
<button className="flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left w-full"> <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 className="size-3" />
Server Stats {t("networkGraph.serverStats")}
</button> </button>
</div> </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"> <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>
)} )}
<div className="absolute bottom-2 left-3 flex items-center gap-3 pointer-events-none"> <div className="absolute bottom-2 left-3 flex items-center gap-3 pointer-events-none">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-accent-brand inline-block" /> <span className="size-1.5 rounded-full bg-accent-brand inline-block" />
<span className="text-[10px] text-muted-foreground font-mono"> <span className="text-[10px] text-muted-foreground font-mono">
Online {t("dashboardTab.onlineLower")}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-muted-foreground/50 inline-block" /> <span className="size-1.5 rounded-full bg-muted-foreground/50 inline-block" />
<span className="text-[10px] text-muted-foreground font-mono"> <span className="text-[10px] text-muted-foreground font-mono">
Offline {t("dashboardTab.offlineLower")}
</span> </span>
</div> </div>
</div> </div>
@@ -732,6 +776,9 @@ function CardItem({
onOpenTab, onOpenTab,
hosts, hosts,
uptimeFormatted, uptimeFormatted,
versionText,
versionStatus,
dbHealth,
credentialCount, credentialCount,
activeTunnelCount, activeTunnelCount,
activity, activity,
@@ -749,6 +796,9 @@ function CardItem({
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
hosts: Host[]; hosts: Host[];
uptimeFormatted: string; uptimeFormatted: string;
versionText: string;
versionStatus: "up_to_date" | "requires_update" | "beta";
dbHealth: "healthy" | "error";
credentialCount: number; credentialCount: number;
activeTunnelCount: number; activeTunnelCount: number;
activity: RecentActivityItem[]; activity: RecentActivityItem[];
@@ -776,15 +826,6 @@ function CardItem({
); );
const isFlex = slot.height === null; const isFlex = slot.height === null;
const cardProps = {
hosts,
uptimeFormatted,
credentialCount,
activeTunnelCount,
activity,
onOpenTab,
onOpenSingletonTab,
};
return ( return (
<div <div
@@ -815,37 +856,35 @@ function CardItem({
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
{slot.id === "stats_bar" && ( {slot.id === "stats_bar" && (
<StatsBarCard <StatsBarCard
hosts={cardProps.hosts} hosts={hosts}
uptimeFormatted={cardProps.uptimeFormatted} uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
dbHealth={dbHealth}
/> />
)} )}
{slot.id === "counters_bar" && ( {slot.id === "counters_bar" && (
<CountersBarCard <CountersBarCard
hosts={cardProps.hosts} hosts={hosts}
credentialCount={cardProps.credentialCount} credentialCount={credentialCount}
activeTunnelCount={cardProps.activeTunnelCount} activeTunnelCount={activeTunnelCount}
/> />
)} )}
{slot.id === "quick_actions" && ( {slot.id === "quick_actions" && (
<QuickActionsCard onOpenSingletonTab={cardProps.onOpenSingletonTab} /> <QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
)} )}
{slot.id === "host_status" && ( {slot.id === "host_status" && (
<HostStatusCard <HostStatusCard hosts={hosts} onOpenTab={onOpenTab} />
hosts={cardProps.hosts}
onOpenTab={cardProps.onOpenTab}
/>
)} )}
{slot.id === "recent_activity" && ( {slot.id === "recent_activity" && (
<RecentActivityCard <RecentActivityCard
activity={activity} activity={activity}
hosts={cardProps.hosts} hosts={hosts}
onOpenTab={cardProps.onOpenTab} onOpenTab={onOpenTab}
onClear={onClearActivity} onClear={onClearActivity}
/> />
)} )}
{slot.id === "network_graph" && ( {slot.id === "network_graph" && <NetworkGraphCard hosts={hosts} />}
<NetworkGraphCard hosts={cardProps.hosts} />
)}
</div> </div>
{editMode && !isFlex && ( {editMode && !isFlex && (
<div <div
@@ -898,16 +937,19 @@ function DropZone({
function AddCardTray({ function AddCardTray({
activeIds, activeIds,
onAdd, onAdd,
cardLabels,
}: { }: {
activeIds: DashboardCardId[]; activeIds: DashboardCardId[];
onAdd: (id: DashboardCardId) => void; onAdd: (id: DashboardCardId) => void;
cardLabels: Record<DashboardCardId, string>;
}) { }) {
const { t } = useTranslation();
const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id)); const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id));
if (available.length === 0) return null; if (available.length === 0) return null;
return ( return (
<div className="flex items-center gap-2 px-1 py-2 flex-wrap shrink-0"> <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"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold shrink-0">
Add: {t("dashboardTab.add")}
</span> </span>
{available.map((card) => ( {available.map((card) => (
<button <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" 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" /> <Plus className="size-3 text-accent-brand" />
{CARD_META[card.id].label} {cardLabels[card.id]}
</button> </button>
))} ))}
</div> </div>
@@ -940,10 +982,14 @@ type PanelColumnProps = {
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
hosts: Host[]; hosts: Host[];
uptimeFormatted: string; uptimeFormatted: string;
versionText: string;
versionStatus: "up_to_date" | "requires_update" | "beta";
dbHealth: "healthy" | "error";
credentialCount: number; credentialCount: number;
activeTunnelCount: number; activeTunnelCount: number;
activity: RecentActivityItem[]; activity: RecentActivityItem[];
onClearActivity: () => void; onClearActivity: () => void;
cardLabels: Record<DashboardCardId, string>;
}; };
function PanelColumn({ function PanelColumn({
@@ -961,11 +1007,16 @@ function PanelColumn({
onOpenTab, onOpenTab,
hosts, hosts,
uptimeFormatted, uptimeFormatted,
versionText,
versionStatus,
dbHealth,
credentialCount, credentialCount,
activeTunnelCount, activeTunnelCount,
activity, activity,
onClearActivity, onClearActivity,
cardLabels,
}: PanelColumnProps) { }: PanelColumnProps) {
const { t } = useTranslation();
const sorted = [...slots].sort((a, b) => a.order - b.order); const sorted = [...slots].sort((a, b) => a.order - b.order);
const allIds = slots.map((s) => s.id); const allIds = slots.map((s) => s.id);
@@ -1007,6 +1058,9 @@ function PanelColumn({
onOpenTab={onOpenTab} onOpenTab={onOpenTab}
hosts={hosts} hosts={hosts}
uptimeFormatted={uptimeFormatted} uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
dbHealth={dbHealth}
credentialCount={credentialCount} credentialCount={credentialCount}
activeTunnelCount={activeTunnelCount} activeTunnelCount={activeTunnelCount}
activity={activity} activity={activity}
@@ -1022,11 +1076,15 @@ function PanelColumn({
active={!!dragState} active={!!dragState}
/> />
{editMode && ( {editMode && (
<AddCardTray activeIds={allIds} onAdd={(id) => onAdd(id, panel)} /> <AddCardTray
activeIds={allIds}
onAdd={(id) => onAdd(id, panel)}
cardLabels={cardLabels}
/>
)} )}
{sorted.length === 0 && !editMode && ( {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"> <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>
)} )}
</div> </div>
@@ -1061,6 +1119,7 @@ export function DashboardTab({
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
}) { }) {
const { t, i18n } = useTranslation();
const [slots, setSlots] = useState<CardSlot[]>(DEFAULT_SLOTS); const [slots, setSlots] = useState<CardSlot[]>(DEFAULT_SLOTS);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [dragState, setDragState] = useState<DragState>(null); const [dragState, setDragState] = useState<DragState>(null);
@@ -1068,6 +1127,11 @@ export function DashboardTab({
const [hosts, setHosts] = useState<Host[]>([]); const [hosts, setHosts] = useState<Host[]>([]);
const [uptimeFormatted, setUptimeFormatted] = 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 [credentialCount, setCredentialCount] = useState(0);
const [activeTunnelCount, setActiveTunnelCount] = useState(0); const [activeTunnelCount, setActiveTunnelCount] = useState(0);
const [activity, setActivity] = useState<RecentActivityItem[]>([]); const [activity, setActivity] = useState<RecentActivityItem[]>([]);
@@ -1079,6 +1143,23 @@ export function DashboardTab({
getUptime() getUptime()
.then((u) => setUptimeFormatted(u.formatted)) .then((u) => setUptimeFormatted(u.formatted))
.catch(() => {}); .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) getRecentActivity(50)
.then(setActivity) .then(setActivity)
.catch(() => {}); .catch(() => {});
@@ -1108,7 +1189,7 @@ export function DashboardTab({
} }
}; };
const todayLabel = new Date().toLocaleDateString("en-US", { const todayLabel = new Date().toLocaleDateString(i18n.language, {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
@@ -1124,6 +1205,15 @@ export function DashboardTab({
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
const hasSide = sideSlots.length > 0; 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( const onColumnDividerMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1211,12 +1301,16 @@ export function DashboardTab({
const columnProps = { const columnProps = {
hosts, hosts,
uptimeFormatted, uptimeFormatted,
versionText,
versionStatus,
dbHealth,
credentialCount, credentialCount,
activeTunnelCount, activeTunnelCount,
activity, activity,
onClearActivity: handleClearActivity, onClearActivity: handleClearActivity,
onOpenSingletonTab, onOpenSingletonTab,
onOpenTab, onOpenTab,
cardLabels,
}; };
const isMobile = useIsMobile(); 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"> <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"> <Card className="flex-row items-center justify-between px-4 py-3 shrink-0 gap-0">
<div> <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> <p className="text-xs text-muted-foreground">{todayLabel}</p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1243,7 +1339,7 @@ export function DashboardTab({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
GitHub {t("dashboard.github")}
</a> </a>
</Button> </Button>
<Button <Button
@@ -1257,7 +1353,7 @@ export function DashboardTab({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Support {t("dashboard.support")}
</a> </a>
</Button> </Button>
<Button <Button
@@ -1271,7 +1367,7 @@ export function DashboardTab({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Discord {t("dashboard.discord")}
</a> </a>
</Button> </Button>
</div> </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" : ""}`} 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 === "stats_bar" && (
<StatsBarCard hosts={hosts} uptimeFormatted={uptimeFormatted} /> <StatsBarCard
hosts={hosts}
uptimeFormatted={uptimeFormatted}
versionText={versionText}
versionStatus={versionStatus}
dbHealth={dbHealth}
/>
)} )}
{slot.id === "counters_bar" && ( {slot.id === "counters_bar" && (
<CountersBarCard <CountersBarCard
@@ -1319,13 +1421,15 @@ export function DashboardTab({
<div className="flex flex-col w-full h-full min-h-0 overflow-hidden"> <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"> <Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0">
<div> <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> <p className="text-xs text-muted-foreground">{todayLabel}</p>
</div> </div>
<div className="flex items-center gap-1"> <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"> <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"> <span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Command Palette {t("dashboardTab.commandPalette")}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Kbd className="h-5 px-1.5 bg-background text-[10px]">Shift</Kbd> <Kbd className="h-5 px-1.5 bg-background text-[10px]">Shift</Kbd>
@@ -1344,7 +1448,7 @@ export function DashboardTab({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
GitHub {t("dashboard.github")}
</a> </a>
</Button> </Button>
<Button <Button
@@ -1358,7 +1462,7 @@ export function DashboardTab({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Support {t("dashboard.support")}
</a> </a>
</Button> </Button>
<Button <Button
@@ -1372,7 +1476,7 @@ export function DashboardTab({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Discord {t("dashboard.discord")}
</a> </a>
</Button> </Button>
<Separator orientation="vertical" className="mx-1 h-5" /> <Separator orientation="vertical" className="mx-1 h-5" />
@@ -1384,14 +1488,14 @@ export function DashboardTab({
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
onClick={handleReset} onClick={handleReset}
> >
Reset {t("dashboard.reset")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white" className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white"
onClick={() => setEditMode(false)} onClick={() => setEditMode(false)}
> >
Done {t("dashboardTab.done")}
</Button> </Button>
</> </>
) : ( ) : (
@@ -1399,7 +1503,7 @@ export function DashboardTab({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditMode(true)} onClick={() => setEditMode(true)}
title="Customize Dashboard" title={t("dashboard.customizeLayout")}
> >
<LayoutDashboard className="size-4 text-accent-brand" /> <LayoutDashboard className="size-4 text-accent-brand" />
</Button> </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"> <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" /> <LayoutDashboard className="size-3.5 text-accent-brand shrink-0" />
<span className="text-xs text-accent-brand font-semibold"> <span className="text-xs text-accent-brand font-semibold">
Drag cards to reorder · Drag the column divider to resize columns · {t("dashboardTab.editModeInstructions")}
Drag the bottom edge of a card to resize its height · Trash to
remove
</span> </span>
</div> </div>
)} )}
+38 -9
View File
@@ -1594,15 +1594,44 @@ export function NetworkGraphCard({
<NetworkIcon className="mr-3" /> <NetworkIcon className="mr-3" />
{t("dashboard.networkGraph")} {t("dashboard.networkGraph")}
</p> </p>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={handleOpenInNewTab} size="sm"
className="flex items-center gap-2 h-8" onClick={() => cyRef.current?.zoom(cyRef.current.zoom() * 1.2)}
title={t("common.openInNewTab")} title={t("networkGraph.zoomIn")}
> className="h-8 w-8 p-0"
<ArrowUp className="w-4 h-4" /> >
</Button> <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> </div>
<AlertDialog open={!!error} onOpenChange={() => setError(null)}> <AlertDialog open={!!error} onOpenChange={() => setError(null)}>
+23 -1
View File
@@ -22,6 +22,21 @@ interface RecentActivityCardProps {
onActivityClick: (item: RecentActivityItem) => void; 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({ export function RecentActivityCard({
activities, activities,
loading, loading,
@@ -95,7 +110,14 @@ export function RecentActivityCard({
) : ( ) : (
<Terminal size={20} className="shrink-0" /> <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> </Button>
)) ))
)} )}
+12 -6
View File
@@ -56,22 +56,28 @@ export function ServerOverviewCard({
<p className="leading-none text-muted-foreground"> <p className="leading-none text-muted-foreground">
{versionText} {versionText}
</p> </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 <Button
variant="outline" variant="outline"
size="sm" 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" {versionStatus === "up_to_date"
? t("dashboard.upToDate") ? t("dashboard.upToDate")
: versionStatus === "beta" : t("dashboard.updateAvailable")}
? t("dashboard.beta")
: t("dashboard.updateAvailable")}
</Button> </Button>
<UpdateLog loggedIn={loggedIn} /> <UpdateLog loggedIn={loggedIn} />
</> </>
)} ) : null}
</div> </div>
</div> </div>
+16 -14
View File
@@ -23,6 +23,10 @@ export function ServerStatsCard({
}: ServerStatsCardProps): React.ReactElement { }: ServerStatsCardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const visibleStats = serverStats.filter(
(s) => s.cpu !== null || s.ram !== null,
);
return ( 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="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"> <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} /> <Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span> <span>{t("dashboard.loadingServerStats")}</span>
</div> </div>
) : serverStats.length === 0 ? ( ) : visibleStats.length === 0 ? (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{t("dashboard.noServerData")} {t("dashboard.noServerData")}
</p> </p>
) : ( ) : (
serverStats.map((server) => ( visibleStats.map((server) => (
<Button <Button
key={server.id} key={server.id}
variant="outline" variant="outline"
@@ -56,18 +60,16 @@ export function ServerStatsCard({
<p className="truncate ml-2 font-semibold">{server.name}</p> <p className="truncate ml-2 font-semibold">{server.name}</p>
</div> </div>
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground"> <div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
<span> {server.cpu !== null && (
{t("dashboard.cpu")}:{" "} <span>
{server.cpu !== null {t("dashboard.cpu")}: {server.cpu.toFixed(1)}%
? `${server.cpu}%` </span>
: t("dashboard.notAvailable")} )}
</span> {server.ram !== null && (
<span> <span>
{t("dashboard.ram")}:{" "} {t("dashboard.ram")}: {server.ram.toFixed(1)}%
{server.ram !== null </span>
? `${server.ram}%` )}
: t("dashboard.notAvailable")}
</span>
</div> </div>
</div> </div>
</Button> </Button>
@@ -5,6 +5,8 @@ import {
type DashboardLayout, type DashboardLayout,
} from "@/main-axios"; } from "@/main-axios";
const LS_KEY = "dashboardLayout";
const DEFAULT_LAYOUT: DashboardLayout = { const DEFAULT_LAYOUT: DashboardLayout = {
cards: [ cards: [
{ id: "server_overview", enabled: true, order: 1, panel: "main" }, { id: "server_overview", enabled: true, order: 1, panel: "main" },
@@ -16,6 +18,42 @@ const DEFAULT_LAYOUT: DashboardLayout = {
mainWidthPct: 68, 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) { export function useDashboardPreferences(enabled: boolean = true) {
const [layout, setLayout] = useState<DashboardLayout | null>(null); const [layout, setLayout] = useState<DashboardLayout | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -28,34 +66,29 @@ export function useDashboardPreferences(enabled: boolean = true) {
return; 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 () => { const fetchPreferences = async () => {
try { try {
const preferences = await getDashboardPreferences(); const preferences = await getDashboardPreferences();
if (preferences?.cards && Array.isArray(preferences.cards)) { if (preferences?.cards && Array.isArray(preferences.cards)) {
// Migrate old layouts that don't have panel assignments const migrated = migrateLayout(preferences);
const needsMigration = preferences.cards.some((c) => !c.panel); setLayout(migrated);
if (needsMigration) { writeToLocalStorage(migrated);
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);
}
} else { } else {
setLayout(DEFAULT_LAYOUT); if (!cached) {
setLayout(DEFAULT_LAYOUT);
}
} }
} catch { } catch {
setLayout(DEFAULT_LAYOUT); if (!cached) {
setLayout(DEFAULT_LAYOUT);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -67,6 +100,7 @@ export function useDashboardPreferences(enabled: boolean = true) {
const updateLayout = useCallback( const updateLayout = useCallback(
(newLayout: DashboardLayout) => { (newLayout: DashboardLayout) => {
setLayout(newLayout); setLayout(newLayout);
writeToLocalStorage(newLayout);
if (saveTimeout) { if (saveTimeout) {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
@@ -87,6 +121,7 @@ export function useDashboardPreferences(enabled: boolean = true) {
const resetLayout = useCallback(async () => { const resetLayout = useCallback(async () => {
setLayout(DEFAULT_LAYOUT); setLayout(DEFAULT_LAYOUT);
writeToLocalStorage(DEFAULT_LAYOUT);
try { try {
await saveDashboardPreferences(DEFAULT_LAYOUT); await saveDashboardPreferences(DEFAULT_LAYOUT);
} catch (error) { } catch (error) {
+126 -48
View File
@@ -529,7 +529,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} }
handleCloseWithError( handleCloseWithError(
t("fileManager.failedToConnect") + ": " + (error.message || error), t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -837,10 +839,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory(); handleRefreshDirectory();
} catch (error: unknown) { } catch (error: unknown) {
toast.dismiss(progressToast); toast.dismiss(progressToast);
const uploadErr = error instanceof Error ? error : null;
if ( if (
error.message?.includes("connection") || uploadErr?.message?.includes("connection") ||
error.message?.includes("established") uploadErr?.message?.includes("established")
) { ) {
toast.error( toast.error(
t("fileManager.sshConnectionFailed", { t("fileManager.sshConnectionFailed", {
@@ -864,21 +866,25 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const response = await downloadSSHFile(sshSessionId, file.path); const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) { const content = response?.content as string | undefined;
const byteCharacters = atob(response.content); 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); const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) { for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i); byteNumbers[i] = byteCharacters.charCodeAt(i);
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream", type: mimeType || "application/octet-stream",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = response.fileName || file.name; link.download = fileName || file.name;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -891,9 +897,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(t("fileManager.failedToDownloadFile")); toast.error(t("fileManager.failedToDownloadFile"));
} }
} catch (error: unknown) { } catch (error: unknown) {
const err = error instanceof Error ? error : null;
if ( if (
error.message?.includes("connection") || err?.message?.includes("connection") ||
error.message?.includes("established") err?.message?.includes("established")
) { ) {
toast.error( toast.error(
t("fileManager.sshConnectionFailed", { t("fileManager.sshConnectionFailed", {
@@ -977,7 +984,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
clearSelection(); clearSelection();
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { const axiosError = error as {
response?: { data?: { needsSudo?: boolean; error?: string } }; response?: {
data?: { needsSudo?: boolean; error?: string };
status?: number;
};
message?: string; message?: string;
}; };
if (axiosError.response?.data?.needsSudo) { if (axiosError.response?.data?.needsSudo) {
@@ -986,11 +996,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return; return;
} }
if ( 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("connection") ||
axiosError.message?.includes("established") axiosError.message?.includes("established")
) { ) {
toast.error( 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 { } else {
toast.error(t("fileManager.failedToDeleteItems")); toast.error(t("fileManager.failedToDeleteItems"));
@@ -1305,16 +1326,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error(`Failed to ${operation} file ${file.name}:`, error); console.error(`Failed to ${operation} file ${file.name}:`, error);
toast.error( const axiosError = error as {
t("fileManager.operationFailed", { response?: { status?: number; data?: { error?: string } };
operation: };
operation === "copy" if (
? t("fileManager.copy") axiosError.response?.status === 403 ||
: t("fileManager.move"), axiosError.response?.data?.error
name: file.name, ?.toLowerCase()
error: error.message, .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); setClipboard(null);
} }
} catch (error: unknown) { } catch (error: unknown) {
toast.error( const errorMessage =
`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`, error instanceof Error ? error.message : String(error);
); toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
} }
} }
@@ -1433,10 +1466,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory(); handleRefreshDirectory();
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { message?: string }; const errorMessage =
toast.error( error instanceof Error ? error.message : String(error);
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`, toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
);
} }
} }
@@ -1478,10 +1510,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory(); handleRefreshDirectory();
clearSelection(); clearSelection();
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { message?: string }; const errorMessage =
toast.error( error instanceof Error ? error.message : String(error);
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`, toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
);
} }
} }
@@ -1521,7 +1552,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error( toast.error(
t("fileManager.deleteCopiedFileFailed", { t("fileManager.deleteCopiedFileFailed", {
name: copiedFile.targetName, 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( toast.error(
t("fileManager.moveBackFileFailed", { t("fileManager.moveBackFileFailed", {
name: movedFile.targetName, name: movedFile.targetName,
error: error.message, error:
error instanceof Error ? error.message : String(error),
}), }),
); );
} }
@@ -1596,9 +1629,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory(); handleRefreshDirectory();
} catch (error: unknown) { } catch (error: unknown) {
toast.error( const errorMessage =
`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`, error instanceof Error ? error.message : String(error);
); toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
console.error("Undo failed:", error); console.error("Undo failed:", error);
} }
} }
@@ -1693,8 +1726,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setCreateIntent(null); setCreateIntent(null);
handleRefreshDirectory(); handleRefreshDirectory();
} catch (error: unknown) { } 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); console.error("Create failed:", error);
toast.error(t("fileManager.failedToCreateItem"));
} }
} }
@@ -1722,8 +1767,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(null); setEditingFile(null);
handleRefreshDirectory(); handleRefreshDirectory();
} catch (error: unknown) { } 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); console.error("Rename failed:", error);
toast.error(t("fileManager.failedToRenameItem"));
} }
} }
@@ -1907,7 +1964,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setAuthDialogReason("auth_failed"); setAuthDialogReason("auth_failed");
setShowAuthDialog(true); setShowAuthDialog(true);
toast.error( toast.error(
t("fileManager.failedToConnect") + ": " + (error.message || error), t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -1976,7 +2035,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error( toast.error(
t("fileManager.moveFileFailed", { name: file.name }) + 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) { } catch (error: unknown) {
console.error("Drag move operation failed:", error); 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( toast.error(
t("fileManager.dragFailed") + 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 ( return (
<div className="h-full flex flex-col bg-background relative"> <div className="h-full flex flex-col bg-background relative">
<div <div
@@ -2823,10 +2905,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}} }}
onSubmit={handleSudoPasswordSubmit} onSubmit={handleSudoPasswordSubmit}
/> />
<SimpleLoader
visible={(isReconnecting || isLoading) && !isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
<ConnectionLog <ConnectionLog
isConnecting={isReconnecting || isLoading} isConnecting={isReconnecting || isLoading}
isConnected={!!sshSessionId} 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 { cn } from "@/lib/utils.ts";
import { import {
Download, Download,
@@ -143,14 +143,6 @@ export function FileManagerContextMenu({
setIsMounted(true); 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; let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -206,6 +198,21 @@ export function FileManagerContextMenu({
}; };
}, [isVisible, x, y, onClose]); }, [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 isFileContext = files.length > 0;
const isSingleFile = files.length === 1; const isSingleFile = files.length === 1;
const isMultipleFiles = 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 items-center gap-2.5 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div> <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> </div>
{item.shortcut && ( {item.shortcut && (
<div className="ml-2 flex-shrink-0"> <div className="ml-2 flex-shrink-0">
@@ -4,7 +4,8 @@ import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx"; import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next"; 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 { interface PdfPreviewProps {
content: string; content: string;
@@ -3,6 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
import { Terminal } from "@/features/terminal/Terminal.tsx"; import { Terminal } from "@/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx"; import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -96,29 +97,31 @@ export function TerminalWindow({
: t("terminal.terminalTitle", { host: hostConfig.name }); : t("terminal.terminalTitle", { host: hostConfig.name });
return ( return (
<DraggableWindow <CommandHistoryProvider>
title={terminalTitle} <DraggableWindow
initialX={initialX} title={terminalTitle}
initialY={initialY} initialX={initialX}
initialWidth={800} initialY={initialY}
initialHeight={500} initialWidth={800}
minWidth={600} initialHeight={500}
minHeight={400} minWidth={600}
onClose={handleClose} minHeight={400}
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} onClose={handleClose}
/> onMaximize={handleMaximize}
</DraggableWindow> 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", "connectionLogClear": "Clear logs",
"connectionLogEmpty": "No connection logs yet", "connectionLogEmpty": "No connection logs yet",
"connectionLogConnecting": "Connecting to host...", "connectionLogConnecting": "Connecting to host...",
"connectionLogWaiting": "Waiting for connection logs...",
"connectionLogCopied": "Connection logs copied to clipboard", "connectionLogCopied": "Connection logs copied to clipboard",
"connectionLogCopyFailed": "Failed to copy logs to clipboard", "connectionLogCopyFailed": "Failed to copy logs to clipboard",
"allAuthMethodsFailed": "All authentication methods failed. Please check your credentials.", "allAuthMethodsFailed": "All authentication methods failed. Please check your credentials.",
@@ -2774,7 +2775,31 @@
"quickActionsCard": "Quick Actions", "quickActionsCard": "Quick Actions",
"serverStatsCard": "Server Stats", "serverStatsCard": "Server Stats",
"panelMain": "Main", "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": { "networkGraph": {
"title": "Network Graph", "title": "Network Graph",
+1 -1
View File
@@ -69,7 +69,7 @@ export function TabBar({
const barRect = tabBarRef.current.getBoundingClientRect(); const barRect = tabBarRef.current.getBoundingClientRect();
const x = Math.max( const x = Math.max(
barRect.left, 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; const y = d.barTop;
setDragPos({ x, y }); 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; return "children" in item;
} }
const SSH_ACTIONS: { type: TabType; icon: typeof Terminal; label: string }[] = [ function getSshActions(
{ type: "terminal", icon: Terminal, label: "Terminal" }, host: Host,
{ type: "stats", icon: Server, label: "Stats" }, ): { type: TabType; icon: typeof Terminal; label: string }[] {
{ type: "files", icon: FolderSearch, label: "Files" }, const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
{ type: "docker", icon: Box, label: "Docker" }, return [
{ type: "tunnel", icon: Network, label: "Tunnel" }, 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) { function hostMatchesQuery(host: Host, query: string) {
return ( return (
@@ -142,43 +171,42 @@ export function HostItem({
opacity: hovered ? 1 : 0, opacity: hovered ? 1 : 0,
}} }}
> >
{host.online && {host.online && (host.cpu != null || host.ram != null) && (
(host.cpu !== undefined || host.ram !== undefined) && ( <div className="flex items-center gap-3 pl-3">
<div className="flex items-center gap-3 pl-3"> {host.cpu != null && (
{host.cpu !== undefined && ( <div className="flex items-center gap-1">
<div className="flex items-center gap-1"> <Cpu className="size-2.5 shrink-0 text-muted-foreground/30" />
<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="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden"> <div
<div className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
className={`h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} style={{ width: `${host.cpu}%` }}
style={{ width: `${host.cpu}%` }} />
/>
</div>
<span className="text-[9px] tabular-nums text-muted-foreground/40">
{host.cpu}%
</span>
</div> </div>
)} <span className="text-[9px] tabular-nums text-muted-foreground/40">
{host.ram !== undefined && ( {host.cpu}%
<div className="flex items-center gap-1"> </span>
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/30" /> </div>
<div className="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden"> )}
<div {host.ram != null && (
className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} <div className="flex items-center gap-1">
style={{ width: `${host.ram}%` }} <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> <div
<span className="text-[9px] tabular-nums text-muted-foreground/40"> className={`h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
{host.ram}% style={{ width: `${host.ram}%` }}
</span> />
</div> </div>
)} <span className="text-[9px] tabular-nums text-muted-foreground/40">
</div> {host.ram}%
)} </span>
</div>
)}
</div>
)}
<div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1"> <div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1">
{host.enableSsh && {host.enableSsh &&
SSH_ACTIONS.map(({ type, icon: Icon, label }) => ( getSshActions(host).map(({ type, icon: Icon, label }) => (
<button <button
key={type} key={type}
title={label} title={label}
+1 -1
View File
@@ -158,7 +158,7 @@ export function ConnectionLog({
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground"> <div className="py-4 text-center text-sm text-muted-foreground">
{isConnecting {isConnecting
? t("terminal.connectionLogConnecting") ? t("terminal.connectionLogWaiting")
: t("terminal.connectionLogEmpty")} : t("terminal.connectionLogEmpty")}
</div> </div>
) : ( ) : (