feat: begin dashboard overhaul by splitting into cards and adding customization

This commit is contained in:
LukeGus
2026-01-16 03:08:10 -06:00
parent 004ddcb2bb
commit c7872770a1
21 changed files with 2328 additions and 1525 deletions

View File

@@ -358,6 +358,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/dashboard/preferences(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30008/;
proxy_http_version 1.1;

View File

@@ -347,6 +347,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/dashboard/preferences(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30008/;
proxy_http_version 1.1;

View File

@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
"version": "1.10.0",
"version": "1.10.1",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",

View File

@@ -1,9 +1,14 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import { getDb } from "./database/db/index.js";
import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
import { eq, and, desc, or } from "drizzle-orm";
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
import {
recentActivity,
sshData,
hostAccess,
dashboardPreferences,
} from "./database/db/schema.js";
import { eq, and, desc, or, sql } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
@@ -350,6 +355,166 @@ app.delete("/activity/reset", async (req, res) => {
}
});
/**
* @openapi
* /dashboard/preferences:
* get:
* summary: Get dashboard layout preferences
* description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
* tags:
* - Dashboard
* responses:
* 200:
* description: Dashboard preferences retrieved
* content:
* application/json:
* schema:
* type: object
* properties:
* cards:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* enabled:
* type: boolean
* order:
* type: integer
* gridColumns:
* type: integer
* 401:
* description: Session expired
* 500:
* description: Failed to get preferences
*/
app.get("/dashboard/preferences", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const preferences = await getDb()
.select()
.from(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
if (preferences.length === 0) {
const defaultLayout = {
cards: [
{ id: "server_overview", enabled: true, order: 1 },
{ id: "recent_activity", enabled: true, order: 2 },
{ id: "network_graph", enabled: false, order: 3 },
{ id: "quick_actions", enabled: true, order: 4 },
{ id: "server_stats", enabled: true, order: 5 },
],
gridColumns: 2,
};
return res.json(defaultLayout);
}
const layout = JSON.parse(preferences[0].layout as string);
res.json(layout);
} catch (err) {
dashboardLogger.error("Failed to get dashboard preferences", err);
res.status(500).json({ error: "Failed to get dashboard preferences" });
}
});
/**
* @openapi
* /dashboard/preferences:
* post:
* summary: Save dashboard layout preferences
* description: Saves or updates the user's customized dashboard layout settings.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* cards:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* enabled:
* type: boolean
* order:
* type: integer
* gridColumns:
* type: integer
* responses:
* 200:
* description: Preferences saved successfully
* 400:
* description: Invalid request body
* 401:
* description: Session expired
* 500:
* description: Failed to save preferences
*/
app.post("/dashboard/preferences", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const { cards, gridColumns } = req.body;
if (!cards || !Array.isArray(cards) || typeof gridColumns !== "number") {
return res.status(400).json({
error:
"Invalid request body. Expected { cards: Array, gridColumns: number }",
});
}
const layout = JSON.stringify({ cards, gridColumns });
const existing = await getDb()
.select()
.from(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
if (existing.length > 0) {
await getDb()
.update(dashboardPreferences)
.set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(eq(dashboardPreferences.userId, userId));
} else {
await getDb().insert(dashboardPreferences).values({ userId, layout });
}
await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
dashboardLogger.success("Dashboard preferences saved", {
operation: "save_dashboard_preferences",
userId,
});
res.json({ success: true, message: "Dashboard preferences saved" });
} catch (err) {
dashboardLogger.error("Failed to save dashboard preferences", err);
res.status(500).json({ error: "Failed to save dashboard preferences" });
}
});
const PORT = 30006;
app.listen(PORT, async () => {
try {

View File

@@ -703,6 +703,30 @@ const migrateSchema = () => {
}
}
try {
sqlite
.prepare("SELECT id FROM dashboard_preferences LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS dashboard_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create dashboard_preferences table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
} catch {

View File

@@ -324,6 +324,21 @@ export const networkTopology = sqliteTable("network_topology", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const dashboardPreferences = sqliteTable("dashboard_preferences", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")

View File

@@ -2261,7 +2261,20 @@
"noServerData": "No server data available",
"cpu": "CPU",
"ram": "RAM",
"notAvailable": "N/A"
"notAvailable": "N/A",
"customizeLayout": "Customize Dashboard",
"dashboardSettings": "Dashboard Settings",
"enableDisableCards": "Enable/Disable Cards",
"gridColumns": "Grid Columns",
"column": "Column",
"columns": "Columns",
"resetLayout": "Reset to Default",
"serverOverviewCard": "Server Overview",
"recentActivityCard": "Recent Activity",
"networkGraphCard": "Network Graph",
"quickActionsCard": "Quick Actions",
"serverStatsCard": "Server Stats",
"networkGraph": "Network Graph"
},
"rbac": {
"shareHost": "Share Host",

View File

@@ -11,7 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { Toaster } from "@/components/ui/sonner.tsx";
import { toast } from "sonner";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
@@ -414,11 +414,10 @@ function AppContent() {
{showNetworkGraph && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<NetworkGraphView
<NetworkGraphCard
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
isStandalone={true}
/>
</div>
)}

View File

@@ -1,10 +1,10 @@
import NetworkGraphView from "@/ui/desktop/dashboard/network-graph/NetworkGraphView";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import React from "react";
const NetworkGraphApp: React.FC = () => {
return (
<div className="w-full h-screen">
<NetworkGraphView />
<div className="w-full h-screen flex flex-col">
<NetworkGraphCard />
</div>
);
};

View File

@@ -1,7 +1,5 @@
import React, { useEffect, useState } from "react";
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph";
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
@@ -11,7 +9,6 @@ import {
getUptime,
getVersionInfo,
getSSHHosts,
getTunnelStatuses,
getCredentials,
getRecentActivity,
resetRecentActivity,
@@ -21,29 +18,16 @@ import {
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
ChartLine,
Clock,
Database,
FastForward,
History,
Key,
Network,
Server,
UserPlus,
Settings,
User,
Loader2,
Terminal,
FolderOpen,
Activity,
Container,
ArrowDownUp,
} from "lucide-react";
import { Status } from "@/components/ui/shadcn-io/status";
import { BsLightning } from "react-icons/bs";
import { Kbd } from "@/components/ui/kbd";
import { useTranslation } from "react-i18next";
import { Settings as SettingsIcon } from "lucide-react";
import { ServerOverviewCard } from "@/ui/desktop/apps/dashboard/cards/ServerOverviewCard";
import { RecentActivityCard } from "@/ui/desktop/apps/dashboard/cards/RecentActivityCard";
import { QuickActionsCard } from "@/ui/desktop/apps/dashboard/cards/QuickActionsCard";
import { ServerStatsCard } from "@/ui/desktop/apps/dashboard/cards/ServerStatsCard";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { useDashboardPreferences } from "@/ui/desktop/apps/dashboard/hooks/useDashboardPreferences";
import { DashboardSettingsDialog } from "@/ui/desktop/apps/dashboard/components/DashboardSettingsDialog";
interface DashboardProps {
onSelectView: (view: string) => void;
@@ -94,9 +78,15 @@ export function Dashboard({
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
>([]);
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
const [showNetworkGraph, setShowNetworkGraph] = useState<boolean>(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
const {
layout,
loading: preferencesLoading,
updateLayout,
resetLayout,
} = useDashboardPreferences();
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
@@ -436,8 +426,18 @@ export function Dashboard({
>
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
<div className="text-2xl text-foreground font-semibold shrink-0">
{t("dashboard.title")}
<div className="flex flex-row items-center gap-3">
<div className="text-2xl text-foreground font-semibold shrink-0">
{t("dashboard.title")}
</div>
<Button
variant="outline"
size="sm"
className="font-semibold shrink-0 !bg-canvas"
onClick={() => setSettingsDialogOpen(true)}
>
{t("dashboard.customizeLayout")}
</Button>
</div>
<div className="flex flex-row gap-3 flex-wrap min-w-0">
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
@@ -496,426 +496,91 @@ export function Dashboard({
<Separator className="mt-3 p-0.25" />
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<Server className="mr-3" />
{t("dashboard.serverOverview")}
</p>
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">
{versionText}
</p>
<Button
variant="outline"
size="sm"
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")
: t("dashboard.updateAvailable")}
</Button>
<UpdateLog loggedIn={loggedIn} />
</div>
</div>
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Clock size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">
{uptime}
</p>
</div>
</div>
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Database size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</p>
</div>
<div className="flex flex-row items-center">
<p
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</p>
</div>
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Server size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalServers}
</p>
</div>
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<ArrowDownUp size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalTunnels}
</p>
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Key size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalCredentials}
</p>
</div>
</div>
</div>
</div>
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
{showNetworkGraph ? (
<>
<Network className="mr-3" />
{t("dashboard.networkGraph", {
defaultValue: "Network Graph",
})}
</>
) : (
<>
<Clock className="mr-3" />
{t("dashboard.recentActivity")}
</>
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
onClick={() => setShowNetworkGraph(!showNetworkGraph)}
>
{showNetworkGraph ? "Show Activity" : "Show Graph"}
</Button>
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
onClick={handleResetActivity}
>
{t("dashboard.reset")}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="border-2 !border-edge h-7 !bg-canvas"
onClick={handleResetActivity}
>
{t("dashboard.reset")}
</Button>
</div>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : recentActivity.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity
.filter((item, index, array) => {
if (index === 0) return true;
const prevItem = array[index - 1];
return !(
item.hostId === prevItem.hostId &&
item.type === prevItem.type
);
})
.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-edge !bg-canvas min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : item.type === "file_manager" ? (
<FolderOpen size={20} className="shrink-0" />
) : item.type === "server_stats" ? (
<Server size={20} className="shrink-0" />
) : item.type === "tunnel" ? (
<ArrowDownUp size={20} className="shrink-0" />
) : item.type === "docker" ? (
<Container size={20} className="shrink-0" />
) : (
<Terminal size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
{showNetworkGraph ? (
<NetworkGraphView />
) : (
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : recentActivity.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
{t("dashboard.quickActions")}
</p>
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleAddHost}
>
<div className="flex flex-col items-center w-full max-w-full">
<Server
className="shrink-0"
style={{ width: "40px", height: "40px" }}
{!preferencesLoading && layout && (
<div
className="grid gap-4 flex-1 min-h-0 auto-rows-fr"
style={{
gridTemplateColumns: `repeat(${layout.gridColumns}, minmax(0, 1fr))`,
}}
>
{layout.cards
.filter((card) => card.enabled)
.sort((a, b) => a.order - b.order)
.map((card) => {
if (card.id === "server_overview") {
return (
<ServerOverviewCard
key={card.id}
loggedIn={loggedIn}
versionText={versionText}
versionStatus={versionStatus}
uptime={uptime}
dbHealth={dbHealth}
totalServers={totalServers}
totalTunnels={totalTunnels}
totalCredentials={totalCredentials}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addHost")}
</span>
</div>
</Button>
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleAddCredential}
>
<div className="flex flex-col items-center w-full max-w-full">
<Key
className="shrink-0"
style={{ width: "40px", height: "40px" }}
);
} else if (card.id === "recent_activity") {
return (
<RecentActivityCard
key={card.id}
activities={recentActivity}
loading={recentActivityLoading}
onReset={handleResetActivity}
onActivityClick={handleActivityClick}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addCredential")}
</span>
</div>
</Button>
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleOpenAdminSettings}
>
<div className="flex flex-col items-center w-full max-w-full">
<Settings
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.adminSettings")}
</span>
</div>
</Button>
)}
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleOpenUserProfile}
>
<div className="flex flex-col items-center w-full max-w-full">
<User
className="shrink-0"
style={{ width: "40px", height: "40px" }}
);
} else if (card.id === "network_graph") {
return (
<NetworkGraphCard
key={card.id}
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.userProfile")}
</span>
</div>
</Button>
</div>
</div>
);
} else if (card.id === "quick_actions") {
return (
<QuickActionsCard
key={card.id}
isAdmin={isAdmin}
onAddHost={handleAddHost}
onAddCredential={handleAddCredential}
onOpenAdminSettings={handleOpenAdminSettings}
onOpenUserProfile={handleOpenUserProfile}
/>
);
} else if (card.id === "server_stats") {
return (
<ServerStatsCard
key={card.id}
serverStats={serverStats}
loading={serverStatsLoading}
onServerClick={handleServerStatClick}
/>
);
}
return null;
})}
</div>
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
{t("dashboard.serverStats")}
</p>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{serverStatsLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span>
</div>
) : serverStats.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noServerData")}
</p>
) : (
serverStats.map((server) => (
<Button
key={server.id}
variant="outline"
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0 !bg-canvas"
onClick={() =>
handleServerStatClick(server.id, server.name)
}
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">
<Server size={20} className="shrink-0" />
<p className="truncate ml-2 font-semibold">
{server.name}
</p>
</div>
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
<span>
{t("dashboard.cpu")}:{" "}
{server.cpu !== null
? `${server.cpu}%`
: t("dashboard.notAvailable")}
</span>
<span>
{t("dashboard.ram")}:{" "}
{server.ram !== null
? `${server.ram}%`
: t("dashboard.notAvailable")}
</span>
</div>
</div>
</Button>
))
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}
<AlertManager userId={userId} loggedIn={loggedIn} />
{layout && (
<DashboardSettingsDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
currentLayout={layout}
onSave={updateLayout}
onReset={resetLayout}
/>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FastForward, Server, Key, Settings, User } from "lucide-react";
import { Button } from "@/components/ui/button";
interface QuickActionsCardProps {
isAdmin: boolean;
onAddHost: () => void;
onAddCredential: () => void;
onOpenAdminSettings: () => void;
onOpenUserProfile: () => void;
}
export function QuickActionsCard({
isAdmin,
onAddHost,
onAddCredential,
onOpenAdminSettings,
onOpenUserProfile,
}: QuickActionsCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
{t("dashboard.quickActions")}
</p>
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
onClick={onAddHost}
>
<div className="flex flex-col items-center w-full max-w-full">
<Server
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addHost")}
</span>
</div>
</Button>
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
onClick={onAddCredential}
>
<div className="flex flex-col items-center w-full max-w-full">
<Key
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addCredential")}
</span>
</div>
</Button>
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
onClick={onOpenAdminSettings}
>
<div className="flex flex-col items-center w-full max-w-full">
<Settings
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.adminSettings")}
</span>
</div>
</Button>
)}
<Button
variant="outline"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3"
onClick={onOpenUserProfile}
>
<div className="flex flex-col items-center w-full max-w-full">
<User
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.userProfile")}
</span>
</div>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Clock,
Loader2,
Terminal,
FolderOpen,
Server,
ArrowDownUp,
Container,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { type RecentActivityItem } from "@/ui/main-axios";
interface RecentActivityCardProps {
activities: RecentActivityItem[];
loading: boolean;
onReset: () => void;
onActivityClick: (item: RecentActivityItem) => void;
}
export function RecentActivityCard({
activities,
loading,
onReset,
onActivityClick,
}: RecentActivityCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
<Clock className="mr-3" />
{t("dashboard.recentActivity")}
</p>
<Button
variant="outline"
size="sm"
className="border-2 !border-edge h-7 !bg-canvas"
onClick={onReset}
>
{t("dashboard.reset")}
</Button>
</div>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{loading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : activities.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
activities
.filter((item, index, array) => {
if (index === 0) return true;
const prevItem = array[index - 1];
return !(
item.hostId === prevItem.hostId && item.type === prevItem.type
);
})
.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-edge min-w-0"
onClick={() => onActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : item.type === "file_manager" ? (
<FolderOpen size={20} className="shrink-0" />
) : item.type === "server_stats" ? (
<Server size={20} className="shrink-0" />
) : item.type === "tunnel" ? (
<ArrowDownUp size={20} className="shrink-0" />
) : item.type === "docker" ? (
<Container size={20} className="shrink-0" />
) : (
<Terminal size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">{item.hostName}</p>
</Button>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Server,
History,
Clock,
Database,
Key,
ArrowDownUp,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
interface ServerOverviewCardProps {
loggedIn: boolean;
versionText: string;
versionStatus: "up_to_date" | "requires_update";
uptime: string;
dbHealth: "healthy" | "error";
totalServers: number;
totalTunnels: number;
totalCredentials: number;
}
export function ServerOverviewCard({
loggedIn,
versionText,
versionStatus,
uptime,
dbHealth,
totalServers,
totalTunnels,
totalCredentials,
}: ServerOverviewCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<Server className="mr-3" />
{t("dashboard.serverOverview")}
</p>
<div className="w-full h-auto border-2 border-edge rounded-md px-3 py-3">
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">
{versionText}
</p>
<Button
variant="outline"
size="sm"
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")
: t("dashboard.updateAvailable")}
</Button>
<UpdateLog loggedIn={loggedIn} />
</div>
</div>
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Clock size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">{uptime}</p>
</div>
</div>
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Database size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</p>
</div>
<div className="flex flex-row items-center">
<p
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</p>
</div>
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Server size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalServers}
</p>
</div>
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<ArrowDownUp size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalTunnels}
</p>
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Key size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalCredentials}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ChartLine, Loader2, Server } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ServerStat {
id: number;
name: string;
cpu: number | null;
ram: number | null;
}
interface ServerStatsCardProps {
serverStats: ServerStat[];
loading: boolean;
onServerClick: (serverId: number, serverName: string) => void;
}
export function ServerStatsCard({
serverStats,
loading,
onServerClick,
}: ServerStatsCardProps): React.ReactElement {
const { t } = useTranslation();
return (
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
{t("dashboard.serverStats")}
</p>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{loading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span>
</div>
) : serverStats.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noServerData")}
</p>
) : (
serverStats.map((server) => (
<Button
key={server.id}
variant="outline"
className="border-2 !border-edge h-auto p-3 min-w-0"
onClick={() => onServerClick(server.id, server.name)}
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">
<Server size={20} className="shrink-0" />
<p className="truncate ml-2 font-semibold">{server.name}</p>
</div>
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
<span>
{t("dashboard.cpu")}:{" "}
{server.cpu !== null
? `${server.cpu}%`
: t("dashboard.notAvailable")}
</span>
<span>
{t("dashboard.ram")}:{" "}
{server.ram !== null
? `${server.ram}%`
: t("dashboard.notAvailable")}
</span>
</div>
</div>
</Button>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
import type { DashboardLayout } from "@/ui/main-axios";
interface DashboardSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentLayout: DashboardLayout;
onSave: (layout: DashboardLayout) => void;
onReset: () => void;
}
export function DashboardSettingsDialog({
open,
onOpenChange,
currentLayout,
onSave,
onReset,
}: DashboardSettingsDialogProps): React.ReactElement {
const { t } = useTranslation();
const [layout, setLayout] = useState<DashboardLayout>(currentLayout);
useEffect(() => {
setLayout(currentLayout);
}, [currentLayout, open]);
const handleCardToggle = (cardId: string, enabled: boolean) => {
setLayout((prev) => ({
...prev,
cards: prev.cards.map((card) =>
card.id === cardId ? { ...card, enabled } : card,
),
}));
};
const handleGridColumnsChange = (value: string) => {
setLayout((prev) => ({
...prev,
gridColumns: parseInt(value, 10),
}));
};
const handleSave = () => {
onSave(layout);
onOpenChange(false);
};
const handleReset = () => {
onReset();
onOpenChange(false);
};
const cardLabels: Record<string, string> = {
server_overview: t("dashboard.serverOverviewCard"),
recent_activity: t("dashboard.recentActivityCard"),
network_graph: t("dashboard.networkGraphCard"),
quick_actions: t("dashboard.quickActionsCard"),
server_stats: t("dashboard.serverStatsCard"),
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>{t("dashboard.dashboardSettings")}</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("dashboard.customizeLayout")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-3">
<Label className="text-base font-semibold">
{t("dashboard.enableDisableCards")}
</Label>
<div className="space-y-3">
{layout.cards.map((card) => (
<div
key={card.id}
className="flex items-center space-x-3 border-2 border-edge rounded-md p-3"
>
<Checkbox
id={card.id}
checked={card.enabled}
onCheckedChange={(checked) =>
handleCardToggle(card.id, checked === true)
}
/>
<Label
htmlFor={card.id}
className="text-sm font-normal cursor-pointer flex-1"
>
{cardLabels[card.id] || card.id}
</Label>
</div>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-base font-semibold">
{t("dashboard.gridColumns")}
</Label>
<Select
value={layout.gridColumns.toString()}
onValueChange={handleGridColumnsChange}
>
<SelectTrigger className="w-full border-2 border-edge">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
1 {t("dashboard.column", { count: 1 })}
</SelectItem>
<SelectItem value="2">
2 {t("dashboard.columns", { count: 2 })}
</SelectItem>
<SelectItem value="3">
3 {t("dashboard.columns", { count: 3 })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="flex-row gap-2">
<Button
variant="outline"
onClick={handleReset}
className="border-2 border-edge"
>
{t("dashboard.resetLayout")}
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-2 border-edge"
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
import { useState, useEffect, useCallback } from "react";
import {
getDashboardPreferences,
saveDashboardPreferences,
type DashboardLayout,
} from "@/ui/main-axios";
const DEFAULT_LAYOUT: DashboardLayout = {
cards: [
{ id: "server_overview", enabled: true, order: 1 },
{ id: "recent_activity", enabled: true, order: 2 },
{ id: "network_graph", enabled: false, order: 3 },
{ id: "quick_actions", enabled: true, order: 4 },
{ id: "server_stats", enabled: true, order: 5 },
],
gridColumns: 2,
};
export function useDashboardPreferences() {
const [layout, setLayout] = useState<DashboardLayout | null>(null);
const [loading, setLoading] = useState(true);
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
const fetchPreferences = async () => {
try {
const preferences = await getDashboardPreferences();
setLayout(preferences);
} catch (error) {
console.error("Failed to load dashboard preferences:", error);
setLayout(DEFAULT_LAYOUT);
} finally {
setLoading(false);
}
};
fetchPreferences();
}, []);
const updateLayout = useCallback(
(newLayout: DashboardLayout) => {
setLayout(newLayout);
if (saveTimeout) {
clearTimeout(saveTimeout);
}
const timeout = setTimeout(async () => {
try {
await saveDashboardPreferences(newLayout);
} catch (error) {
console.error("Failed to save dashboard preferences:", error);
}
}, 1000);
setSaveTimeout(timeout);
},
[saveTimeout],
);
const resetLayout = useCallback(async () => {
setLayout(DEFAULT_LAYOUT);
try {
await saveDashboardPreferences(DEFAULT_LAYOUT);
} catch (error) {
console.error("Failed to reset dashboard preferences:", error);
}
}, []);
return {
layout,
loading,
updateLayout,
resetLayout,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
export { default as NetworkGraphView } from './NetworkGraphView';

View File

@@ -4,7 +4,7 @@ import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-sta
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
import { NetworkGraphView } from "@/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
ResizablePanelGroup,
@@ -356,11 +356,10 @@ export function AppView({
embedded
/>
) : t.type === "network_graph" ? (
<NetworkGraphView
<NetworkGraphCard
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
isStandalone={true}
/>
) : t.type === "tunnel" ? (
<TunnelManager

View File

@@ -3919,3 +3919,20 @@ export async function getContainerStats(
throw handleApiError(error, "get container stats");
}
}
export interface DashboardLayout {
cards: Array<{ id: string; enabled: boolean; order: number }>;
gridColumns: number;
}
export async function getDashboardPreferences(): Promise<DashboardLayout> {
const response = await dashboardAxios.get("/dashboard/preferences");
return response.data;
}
export async function saveDashboardPreferences(
layout: DashboardLayout,
): Promise<{ success: boolean }> {
const response = await dashboardAxios.post("/dashboard/preferences", layout);
return response.data;
}