first commit
This commit is contained in:
1 parent
abb09fbea8
commit
72f6d49818
355 files changed
+121239
-3
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { Database, Loader2, Play, Square, Trash2 } from "lucide-react";
|
||||
import type { DatabaseContainer } from "./database-types";
|
||||
import { typeColors } from "./database-constants";
|
||||
|
||||
interface DatabaseGridProps {
|
||||
filteredData: DatabaseContainer[];
|
||||
actioning: string | null;
|
||||
removing: string | null;
|
||||
onAction: (container: DatabaseContainer, action: "start" | "stop") => void;
|
||||
onRemove: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function DatabaseGrid({
|
||||
filteredData,
|
||||
actioning,
|
||||
removing,
|
||||
onAction,
|
||||
onRemove,
|
||||
}: DatabaseGridProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(380px, 100%), 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{filteredData.map((db) => {
|
||||
const color =
|
||||
db.databaseColor ?? typeColors[db.databaseType] ?? "#3b82f6";
|
||||
const label = db.databaseLabel;
|
||||
const isRunning = db.status === "RUNNING";
|
||||
const isFailed = db.status === "ERROR";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={db.id}
|
||||
className="card"
|
||||
style={{ padding: 18, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
opacity: 0.05,
|
||||
filter: "blur(20px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 9,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}30`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Database size={16} style={{ color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{db.name}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
{db.exposedPort ? ` · :${db.exposedPort}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
background: isRunning
|
||||
? "rgba(16,185,129,0.1)"
|
||||
: isFailed
|
||||
? "rgba(239,68,68,0.1)"
|
||||
: "rgba(100,116,139,0.1)",
|
||||
color: isRunning
|
||||
? "#10b981"
|
||||
: isFailed
|
||||
? "#ef4444"
|
||||
: "#64748b",
|
||||
border: `1px solid ${isRunning ? "rgba(16,185,129,0.3)" : isFailed ? "rgba(239,68,68,0.3)" : "rgba(100,116,139,0.3)"}`,
|
||||
padding: "3px 9px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{isRunning && (
|
||||
<span
|
||||
className="animate-pulse-dot"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: "50%",
|
||||
background: "currentColor",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{db.status.charAt(0) + db.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: "Server", value: db.server?.name ?? "—" },
|
||||
{ label: "Port", value: db.exposedPort ?? "—" },
|
||||
{ label: "Container", value: db.name ?? "—" },
|
||||
{ label: "Image", value: db.image },
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
style={{
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 7,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{isRunning ? (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onAction(db, "stop")}
|
||||
disabled={actioning === db.id}
|
||||
>
|
||||
{actioning === db.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={11} />
|
||||
)}{" "}
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onAction(db, "start")}
|
||||
disabled={actioning === db.id}
|
||||
>
|
||||
{actioning === db.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={11} />
|
||||
)}{" "}
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onRemove(db.id, db.name)}
|
||||
disabled={removing === db.id}
|
||||
>
|
||||
{removing === db.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}{" "}
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { HardDrive } from "lucide-react";
|
||||
import type { Server } from "@/lib/api";
|
||||
|
||||
interface DatabaseServerFilterProps {
|
||||
serverList: Server[];
|
||||
selectedServerId: string;
|
||||
onChange: (serverId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function getButtonStyles(isSelected: boolean) {
|
||||
return {
|
||||
padding: "5px 14px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "rgba(59,130,246,0.15)" : "var(--bg-input)",
|
||||
color: isSelected ? "#3b82f6" : "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
outline: isSelected
|
||||
? "1px solid rgba(59,130,246,0.3)"
|
||||
: "1px solid var(--border)",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function DatabaseServerFilter({
|
||||
serverList,
|
||||
selectedServerId,
|
||||
onChange,
|
||||
}: DatabaseServerFilterProps) {
|
||||
return (
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<div className="ui-inline-cluster">
|
||||
<HardDrive size={14} style={{ color: "var(--text-muted)" }} />
|
||||
<span
|
||||
className="ui-inline-label"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Server:
|
||||
</span>
|
||||
<div className="ui-chip-scroll no-scrollbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onChange("")}
|
||||
style={getButtonStyles(selectedServerId === "")}
|
||||
>
|
||||
All Servers
|
||||
</button>
|
||||
{serverList.map((server) => (
|
||||
<button
|
||||
type="button"
|
||||
key={server.id}
|
||||
onClick={() => void onChange(server.id)}
|
||||
style={getButtonStyles(selectedServerId === server.id)}
|
||||
>
|
||||
{server.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* <p
|
||||
className="ui-toolbar-note"
|
||||
style={{ fontSize: 12, color: "var(--text-muted)" }}
|
||||
>
|
||||
{selectedServerId
|
||||
? "Sync database container only for the selected server"
|
||||
: "Default mode: show all databases from all servers"}
|
||||
</p> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Database, Loader2, Plus } from "lucide-react";
|
||||
|
||||
interface DatabaseStatePanelProps {
|
||||
loading: boolean;
|
||||
isEmpty: boolean;
|
||||
searchActive: boolean;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function DatabaseStatePanel({
|
||||
loading,
|
||||
isEmpty,
|
||||
searchActive,
|
||||
onAdd,
|
||||
}: DatabaseStatePanelProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Loader2
|
||||
size={28}
|
||||
className="animate-spin"
|
||||
style={{ color: "var(--accent)", margin: "0 auto 12px" }}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
Loading Databases...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
<Database
|
||||
size={36}
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
margin: "0 auto 12px",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
{searchActive
|
||||
? "No database containers match your search"
|
||||
: "No database containers detected on connected Docker hosts"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 16, fontSize: 12 }}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus size={12} /> Deploy First Database
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Activity, CheckCircle, Database, HardDrive } from "lucide-react";
|
||||
|
||||
interface DatabaseSummaryProps {
|
||||
total: number;
|
||||
running: number;
|
||||
serverCount: number;
|
||||
typeCount: number;
|
||||
}
|
||||
|
||||
export default function DatabaseSummary({
|
||||
total,
|
||||
running,
|
||||
serverCount,
|
||||
typeCount,
|
||||
}: DatabaseSummaryProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: "Total DBs",
|
||||
value: total,
|
||||
color: "#3b82f6",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: "Running",
|
||||
value: running,
|
||||
color: "#10b981",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
label: "Servers Used",
|
||||
value: serverCount,
|
||||
color: "#8b5cf6",
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
label: "DB Types",
|
||||
value: typeCount,
|
||||
color: "#f59e0b",
|
||||
icon: Activity,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{stats.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 10,
|
||||
background: `${s.color}15`,
|
||||
border: `1px solid ${s.color}25`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Icon size={18} style={{ color: s.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SearchField from "@/components/SearchField";
|
||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
interface DatabaseToolbarProps {
|
||||
search: string;
|
||||
syncing: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSync: () => void | Promise<void>;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function DatabaseToolbar({
|
||||
search,
|
||||
syncing,
|
||||
onSearchChange,
|
||||
onSync,
|
||||
onAdd,
|
||||
}: DatabaseToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
placeholder="Search databases..."
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
containerStyle={{ flex: "1 1 360px", minWidth: 240 }}
|
||||
/>
|
||||
<div className="ui-toolbar-actions" style={{}}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void onSync()}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Sync from Docker
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus size={12} /> Add Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const DB_APPS = ["postgres", "mysql", "mongodb", "redis"] as const;
|
||||
export type DbType = (typeof DB_APPS)[number];
|
||||
export type DatabaseKind = DbType | "mariadb";
|
||||
|
||||
export const DB_META: Record<
|
||||
DatabaseKind,
|
||||
{ label: string; color: string; port: string; passEnv: string }
|
||||
> = {
|
||||
postgres: {
|
||||
label: "PostgreSQL",
|
||||
color: "#3b82f6",
|
||||
port: "5432:5432",
|
||||
passEnv: "POSTGRES_PASSWORD",
|
||||
},
|
||||
mysql: {
|
||||
label: "MySQL",
|
||||
color: "#f59e0b",
|
||||
port: "3306:3306",
|
||||
passEnv: "MYSQL_ROOT_PASSWORD",
|
||||
},
|
||||
mongodb: {
|
||||
label: "MongoDB",
|
||||
color: "#10b981",
|
||||
port: "27017:27017",
|
||||
passEnv: "MONGO_INITDB_ROOT_PASSWORD",
|
||||
},
|
||||
mariadb: {
|
||||
label: "MariaDB",
|
||||
color: "#fb7185",
|
||||
port: "3306:3306",
|
||||
passEnv: "MARIADB_ROOT_PASSWORD",
|
||||
},
|
||||
redis: { label: "Redis", color: "#ef4444", port: "6379:6379", passEnv: "" },
|
||||
};
|
||||
|
||||
export const typeColors: Record<string, string> = {
|
||||
PostgreSQL: "#3b82f6",
|
||||
MySQL: "#f59e0b",
|
||||
MongoDB: "#10b981",
|
||||
Redis: "#ef4444",
|
||||
postgres: "#3b82f6",
|
||||
mysql: "#f59e0b",
|
||||
mongodb: "#10b981",
|
||||
mariadb: "#fb7185",
|
||||
redis: "#ef4444",
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Container, Server } from "@/lib/api";
|
||||
import type { DatabaseKind } from "./database-constants";
|
||||
|
||||
export type DatabaseContainer = Container & {
|
||||
databaseType: DatabaseKind;
|
||||
databaseLabel: string;
|
||||
databaseColor: string;
|
||||
exposedPort: string;
|
||||
};
|
||||
|
||||
export interface AddDatabaseModalProps {
|
||||
serverList: Server[];
|
||||
initialServerId?: string;
|
||||
initialEnvironmentId?: string;
|
||||
lockServerSelection?: boolean;
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Container } from "@/lib/api";
|
||||
import { DB_META, DatabaseKind } from "./database-constants";
|
||||
import type { DatabaseContainer } from "./database-types";
|
||||
|
||||
export function detectDatabaseType(container: Container): DatabaseKind | null {
|
||||
const source = `${container.name} ${container.image}`.toLowerCase();
|
||||
|
||||
if (source.includes("postgres")) return "postgres";
|
||||
if (source.includes("mariadb")) return "mariadb";
|
||||
if (source.includes("mysql")) return "mysql";
|
||||
if (source.includes("mongo")) return "mongodb";
|
||||
if (source.includes("redis")) return "redis";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractPort(container: Container, fallback: string): string {
|
||||
const firstPort = container.ports[0];
|
||||
if (!firstPort) return fallback;
|
||||
|
||||
const hostMatch = firstPort.match(/:(\d+)->/);
|
||||
if (hostMatch?.[1]) return hostMatch[1];
|
||||
|
||||
const containerMatch = firstPort.match(/(\d+)\/(tcp|udp)/i);
|
||||
if (containerMatch?.[1]) return containerMatch[1];
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function toDatabaseContainer(
|
||||
container: Container,
|
||||
): DatabaseContainer | null {
|
||||
const databaseType = detectDatabaseType(container);
|
||||
if (!databaseType) return null;
|
||||
|
||||
const meta = DB_META[databaseType];
|
||||
|
||||
return {
|
||||
...container,
|
||||
databaseType,
|
||||
databaseLabel: meta.label,
|
||||
databaseColor: meta.color,
|
||||
exposedPort: extractPort(container, meta.port),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
containers as containersApi,
|
||||
servers as serversApi,
|
||||
Server,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
readCachedPageData,
|
||||
readStoredServerSelection,
|
||||
storeServerSelection,
|
||||
writeCachedPageData,
|
||||
} from "@/lib/page-state";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
import AddDatabaseModal from "./components/AddDatabaseModal";
|
||||
import DatabaseServerFilter from "./components/DatabaseServerFilter";
|
||||
import DatabaseStatePanel from "./components/DatabaseStatePanel";
|
||||
import DatabaseSummary from "./components/DatabaseSummary";
|
||||
import DatabaseToolbar from "./components/DatabaseToolbar";
|
||||
import DatabaseGrid from "./components/DatabaseGrid";
|
||||
import { toDatabaseContainer } from "./components/database-utils";
|
||||
import type { DatabaseContainer } from "./components/database-types";
|
||||
|
||||
const PAGE_KEY = "databases";
|
||||
|
||||
type PendingConfirmAction = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger" | "warning" | "info";
|
||||
note?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export default function DatabasesPage() {
|
||||
const { toasts, pushToast, dismissToast } = useToastManager();
|
||||
const [data, setData] = useState<DatabaseContainer[]>([]);
|
||||
const [serverList, setServerList] = useState<Server[]>([]);
|
||||
const [selectedServerId, setSelectedServerId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [actioning, setActioning] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] =
|
||||
useState<PendingConfirmAction | null>(null);
|
||||
|
||||
const applyCachedState = useCallback((serverId: string) => {
|
||||
const cached = readCachedPageData<{
|
||||
items: DatabaseContainer[];
|
||||
serverList: Server[];
|
||||
}>(PAGE_KEY, serverId);
|
||||
|
||||
if (!cached) return false;
|
||||
|
||||
setData(cached.items);
|
||||
setServerList(cached.serverList);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (options?: { sync?: boolean; serverId?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resolvedServerId = options?.serverId ?? "";
|
||||
const sr = await serversApi.list();
|
||||
const nextServers = sr.data ?? [];
|
||||
|
||||
let ir;
|
||||
|
||||
try {
|
||||
ir = options?.sync
|
||||
? await containersApi.sync(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
)
|
||||
: await containersApi.list(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!options?.sync) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
ir = await containersApi.list(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const dbs = (ir.data ?? [])
|
||||
.map(toDatabaseContainer)
|
||||
.filter((item): item is DatabaseContainer => item !== null);
|
||||
setData(dbs);
|
||||
setServerList(nextServers);
|
||||
setSelectedServerId(resolvedServerId);
|
||||
storeServerSelection(PAGE_KEY, resolvedServerId);
|
||||
writeCachedPageData(
|
||||
PAGE_KEY,
|
||||
{
|
||||
items: dbs,
|
||||
serverList: nextServers,
|
||||
},
|
||||
resolvedServerId,
|
||||
);
|
||||
} catch {
|
||||
/* silently fail */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const storedServerId = readStoredServerSelection(PAGE_KEY);
|
||||
setSelectedServerId(storedServerId);
|
||||
const hasCache = applyCachedState(storedServerId);
|
||||
if (!hasCache) {
|
||||
void load({ sync: false, serverId: storedServerId });
|
||||
}
|
||||
}, [applyCachedState, load]);
|
||||
|
||||
const handleAction = async (
|
||||
container: DatabaseContainer,
|
||||
action: "start" | "stop",
|
||||
) => {
|
||||
setActioning(container.id);
|
||||
try {
|
||||
await containersApi.action(container.id, action);
|
||||
setData((current) =>
|
||||
current.map((item) =>
|
||||
item.id === container.id
|
||||
? { ...item, status: action === "start" ? "RUNNING" : "STOPPED" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Database Action",
|
||||
message: `${container.name} ${action === "start" ? "started" : "stopped"} successfully`,
|
||||
showProgress: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Database Action",
|
||||
message: err instanceof Error ? err.message : "Action failed",
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setActioning(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDatabase = async (id: string, name: string) => {
|
||||
setRemoving(id);
|
||||
try {
|
||||
await containersApi.action(id, "rm");
|
||||
setData((prev) => prev.filter((d) => d.id !== id));
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Database Removed",
|
||||
message: `${name} removed successfully`,
|
||||
showProgress: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Database Removed",
|
||||
message: err instanceof Error ? err.message : "Remove failed",
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: string, name: string) => {
|
||||
setConfirmDialog({
|
||||
title: "Remove Database",
|
||||
description: `Remove database "${name}"?`,
|
||||
confirmLabel: "Remove Database",
|
||||
tone: "danger",
|
||||
note: "The database container will be removed from the selected server.",
|
||||
onConfirm: () => {
|
||||
void removeDatabase(id, name);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await load({ sync: true, serverId: selectedServerId });
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Sync Complete",
|
||||
message: "Database inventory refreshed successfully",
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerChange = async (serverId: string) => {
|
||||
if (serverId === selectedServerId) return;
|
||||
storeServerSelection(PAGE_KEY, serverId);
|
||||
setSelectedServerId(serverId);
|
||||
const hasCache = applyCachedState(serverId);
|
||||
if (!hasCache) {
|
||||
await load({ sync: false, serverId });
|
||||
}
|
||||
};
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const filteredData = data.filter((db) => {
|
||||
if (!query) return true;
|
||||
|
||||
return (
|
||||
db.name.toLowerCase().includes(query) ||
|
||||
db.image.toLowerCase().includes(query) ||
|
||||
db.databaseLabel.toLowerCase().includes(query) ||
|
||||
db.databaseType.toLowerCase().includes(query) ||
|
||||
(db.server?.name ?? "").toLowerCase().includes(query) ||
|
||||
(db.server?.ip ?? "").toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
const running = data.filter((d) => d.status === "RUNNING").length;
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Databases"
|
||||
subtitle="Manage database instances across all servers"
|
||||
>
|
||||
<ConfirmActionDialog
|
||||
open={confirmDialog !== null}
|
||||
title={confirmDialog?.title ?? ""}
|
||||
description={confirmDialog?.description ?? ""}
|
||||
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
|
||||
tone={confirmDialog?.tone ?? "danger"}
|
||||
note={confirmDialog?.note}
|
||||
onClose={() => setConfirmDialog(null)}
|
||||
onConfirm={() => {
|
||||
const current = confirmDialog;
|
||||
setConfirmDialog(null);
|
||||
current?.onConfirm();
|
||||
}}
|
||||
/>
|
||||
<ToastViewport
|
||||
toasts={toasts}
|
||||
onClose={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
{showAdd && (
|
||||
<AddDatabaseModal
|
||||
serverList={serverList}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onAdded={() => load({ sync: true, serverId: selectedServerId })}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<DatabaseSummary
|
||||
total={data.length}
|
||||
running={running}
|
||||
serverCount={new Set(data.map((d) => d.serverId)).size}
|
||||
typeCount={new Set(data.map((d) => d.databaseType)).size}
|
||||
/>
|
||||
<DatabaseServerFilter
|
||||
serverList={serverList}
|
||||
selectedServerId={selectedServerId}
|
||||
onChange={handleServerChange}
|
||||
/>
|
||||
<DatabaseToolbar
|
||||
search={search}
|
||||
syncing={syncing}
|
||||
onSearchChange={setSearch}
|
||||
onSync={handleSync}
|
||||
onAdd={() => setShowAdd(true)}
|
||||
/>
|
||||
<DatabaseStatePanel
|
||||
loading={loading}
|
||||
isEmpty={filteredData.length === 0}
|
||||
searchActive={Boolean(search.trim())}
|
||||
onAdd={() => setShowAdd(true)}
|
||||
/>
|
||||
{/* Cards */}
|
||||
{!loading && filteredData.length > 0 && (
|
||||
<DatabaseGrid
|
||||
filteredData={filteredData}
|
||||
actioning={actioning}
|
||||
removing={removing}
|
||||
onAction={handleAction}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user