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