mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
feat: continued migration
This commit is contained in:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="flex flex-col gap-4 overflow-auto min-h-0"
|
|
||||||
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
|
|
||||||
>
|
|
||||||
{mainCards.map((card) => (
|
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
|
className="flex flex-col"
|
||||||
style={
|
style={
|
||||||
card.height
|
card.height
|
||||||
? { height: card.height, flexShrink: 0 }
|
? { height: card.height, flexShrink: 0 }
|
||||||
: { flex: 1, minHeight: 280 }
|
: { flex: 1, minHeight: 280 }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{renderCard(card)}
|
<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>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 overflow-auto min-h-0"
|
||||||
|
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{mainCards.map((card) => renderCardWithHandle(card))}
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1594,16 +1594,45 @@ export function NetworkGraphCard({
|
|||||||
<NetworkIcon className="mr-3" />
|
<NetworkIcon className="mr-3" />
|
||||||
{t("dashboard.networkGraph")}
|
{t("dashboard.networkGraph")}
|
||||||
</p>
|
</p>
|
||||||
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleOpenInNewTab}
|
onClick={handleOpenInNewTab}
|
||||||
className="flex items-center gap-2 h-8"
|
className="h-8 w-8 p-0"
|
||||||
title={t("common.openInNewTab")}
|
title={t("common.openInNewTab")}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4" />
|
<ArrowUp className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={!!error} onOpenChange={() => setError(null)}>
|
<AlertDialog open={!!error} onOpenChange={() => setError(null)}>
|
||||||
<AlertDialogContent className="bg-canvas border-2 border-edge">
|
<AlertDialogContent className="bg-canvas border-2 border-edge">
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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.beta")
|
|
||||||
: t("dashboard.updateAvailable")}
|
: t("dashboard.updateAvailable")}
|
||||||
</Button>
|
</Button>
|
||||||
<UpdateLog loggedIn={loggedIn} />
|
<UpdateLog loggedIn={loggedIn} />
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{server.cpu !== null && (
|
||||||
<span>
|
<span>
|
||||||
{t("dashboard.cpu")}:{" "}
|
{t("dashboard.cpu")}: {server.cpu.toFixed(1)}%
|
||||||
{server.cpu !== null
|
|
||||||
? `${server.cpu}%`
|
|
||||||
: t("dashboard.notAvailable")}
|
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{server.ram !== null && (
|
||||||
<span>
|
<span>
|
||||||
{t("dashboard.ram")}:{" "}
|
{t("dashboard.ram")}: {server.ram.toFixed(1)}%
|
||||||
{server.ram !== null
|
|
||||||
? `${server.ram}%`
|
|
||||||
: t("dashboard.notAvailable")}
|
|
||||||
</span>
|
</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);
|
|
||||||
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);
|
setLayout(migrated);
|
||||||
|
writeToLocalStorage(migrated);
|
||||||
} else {
|
} else {
|
||||||
setLayout(preferences);
|
if (!cached) {
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setLayout(DEFAULT_LAYOUT);
|
setLayout(DEFAULT_LAYOUT);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (!cached) {
|
||||||
setLayout(DEFAULT_LAYOUT);
|
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) {
|
||||||
|
|||||||
@@ -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,6 +1326,17 @@ 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);
|
||||||
|
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(
|
toast.error(
|
||||||
t("fileManager.operationFailed", {
|
t("fileManager.operationFailed", {
|
||||||
operation:
|
operation:
|
||||||
@@ -1312,11 +1344,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
? t("fileManager.copy")
|
? t("fileManager.copy")
|
||||||
: t("fileManager.move"),
|
: t("fileManager.move"),
|
||||||
name: file.name,
|
name: file.name,
|
||||||
error: error.message,
|
error: error instanceof Error ? error.message : String(error),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
if (operation === "copy") {
|
if (operation === "copy") {
|
||||||
@@ -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,9 +1726,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
setCreateIntent(null);
|
setCreateIntent(null);
|
||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Create failed:", error);
|
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"));
|
toast.error(t("fileManager.failedToCreateItem"));
|
||||||
}
|
}
|
||||||
|
console.error("Create failed:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelCreate() {
|
function handleCancelCreate() {
|
||||||
@@ -1722,9 +1767,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
setEditingFile(null);
|
setEditingFile(null);
|
||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Rename failed:", error);
|
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"));
|
toast.error(t("fileManager.failedToRenameItem"));
|
||||||
}
|
}
|
||||||
|
console.error("Rename failed:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStartEdit(file: FileItem) {
|
function handleStartEdit(file: FileItem) {
|
||||||
@@ -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,6 +97,7 @@ export function TerminalWindow({
|
|||||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<CommandHistoryProvider>
|
||||||
<DraggableWindow
|
<DraggableWindow
|
||||||
title={terminalTitle}
|
title={terminalTitle}
|
||||||
initialX={initialX}
|
initialX={initialX}
|
||||||
@@ -120,5 +122,6 @@ export function TerminalWindow({
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>
|
||||||
</DraggableWindow>
|
</DraggableWindow>
|
||||||
|
</CommandHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
+955
-175
File diff suppressed because it is too large
Load Diff
@@ -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,10 +171,9 @@ 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 !== undefined && (
|
{host.cpu != null && (
|
||||||
<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">
|
||||||
@@ -159,7 +187,7 @@ export function HostItem({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{host.ram !== undefined && (
|
{host.ram != null && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/30" />
|
<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="w-9 h-[3px] bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||||
@@ -178,7 +206,7 @@ export function HostItem({
|
|||||||
|
|
||||||
<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}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user