feat: begin dashboard overhaul by splitting into cards and adding customization
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,9 +426,19 @@ 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="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">
|
||||
<p className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
@@ -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>
|
||||
{!preferencesLoading && layout && (
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
className="grid gap-4 flex-1 min-h-0 auto-rows-fr"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${layout.gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{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
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.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" }}
|
||||
} 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.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 === "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.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" }}
|
||||
);
|
||||
} else if (card.id === "quick_actions") {
|
||||
return (
|
||||
<QuickActionsCard
|
||||
key={card.id}
|
||||
isAdmin={isAdmin}
|
||||
onAddHost={handleAddHost}
|
||||
onAddCredential={handleAddCredential}
|
||||
onOpenAdminSettings={handleOpenAdminSettings}
|
||||
onOpenUserProfile={handleOpenUserProfile}
|
||||
/>
|
||||
<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 === "server_stats") {
|
||||
return (
|
||||
<ServerStatsCard
|
||||
key={card.id}
|
||||
serverStats={serverStats}
|
||||
loading={serverStatsLoading}
|
||||
onServerClick={handleServerStatClick}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
return null;
|
||||
})}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
1261
src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
Normal file
1261
src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
141
src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
Normal file
141
src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
Normal file
97
src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
Normal file
142
src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
Normal file
80
src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1 +0,0 @@
|
||||
export { default as NetworkGraphView } from './NetworkGraphView';
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user