first commit

This commit is contained in:
KodekaTeam committed 2026-06-02 00:17:50 +07:00
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),
};
}
+307
View File
@@ -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>
);
}