refactor(ui): consolidate container and database management under projects

This commit is contained in:
KodekaTeam committed 2026-07-14 23:07:41 +07:00
1 parent 742cae643e
commit dbefb42725
26 files changed
+4 -3018

No files matched your search

@@ -1,50 +0,0 @@
import type { ReactNode } from "react";
interface ContainerStatCardProps {
icon: ReactNode;
label: string;
value: string;
subvalue?: string;
}
export default function ContainerStatCard({
icon,
label,
value,
subvalue,
}: ContainerStatCardProps) {
return (
<div
style={{
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 12,
padding: 14,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 10,
}}
>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
{label}
</span>
<span style={{ color: "var(--accent)" }}>{icon}</span>
</div>
<div
style={{ color: "var(--text-primary)", fontSize: 18, fontWeight: 700 }}
>
{value}
</div>
{subvalue ? (
<div style={{ color: "var(--text-muted)", fontSize: 11, marginTop: 4 }}>
{subvalue}
</div>
) : null}
</div>
);
}
@@ -1,72 +0,0 @@
import { HardDrive } from "lucide-react";
import type { Server } from "@/lib/api";
interface ContainersServerFilterProps {
serverList: Server[];
selectedServerId: string;
onChange: (serverId: string) => void | Promise<void>;
}
function getButtonStyle(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 ContainersServerFilter({
serverList,
selectedServerId,
onChange,
}: ContainersServerFilterProps) {
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={getButtonStyle(selectedServerId === "")}
>
All Servers
</button>
{serverList.map((server) => (
<button
type="button"
key={server.id}
onClick={() => void onChange(server.id)}
style={getButtonStyle(selectedServerId === server.id)}
>
{server.name}
</button>
))}
</div>
</div>
</div>
);
}
@@ -1,70 +0,0 @@
interface ContainersSummaryProps {
total: number;
running: number;
stopped: number;
pausedAndError: number;
}
export default function ContainersSummary({
total,
running,
stopped,
pausedAndError,
}: ContainersSummaryProps) {
const stats = [
{ label: "Total", value: total, color: "#3b82f6" },
{ label: "Running", value: running, color: "#10b981" },
{ label: "Stopped", value: stopped, color: "#64748b" },
{ label: "Paused / Error", value: pausedAndError, color: "#ef4444" },
];
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
gap: 12,
}}
>
{stats.map((stat) => (
<div
key={stat.label}
className="card"
style={{
padding: "14px 18px",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<div
style={{
width: 10,
height: 36,
borderRadius: 3,
background: stat.color,
opacity: 0.7,
}}
/>
<div>
<p
style={{
fontSize: 24,
fontWeight: 700,
color: "var(--text-primary)",
lineHeight: 1,
}}
>
{stat.value}
</p>
<p
style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}
>
{stat.label}
</p>
</div>
</div>
))}
</div>
);
}
@@ -1,80 +0,0 @@
import SearchField from "@/components/SearchField";
import { Loader2, Plus, RefreshCw } from "lucide-react";
interface ContainersToolbarProps {
search: string;
statusFilter: string;
syncing: boolean;
onSearchChange: (value: string) => void;
onStatusFilterChange: (value: string) => void;
onSync: () => void | Promise<void>;
onDeploy: () => void;
}
export default function ContainersToolbar({
search,
statusFilter,
syncing,
onSearchChange,
onStatusFilterChange,
onSync,
onDeploy,
}: ContainersToolbarProps) {
return (
<div
className="card ui-responsive-toolbar"
style={{
padding: "12px 16px",
}}
>
<SearchField
placeholder="Search containers..."
value={search}
onChange={(event) => onSearchChange(event.target.value)}
containerStyle={{ flex: "1 1 320px", minWidth: 220 }}
/>
<select
className="input"
value={statusFilter}
onChange={(event) => onStatusFilterChange(event.target.value)}
style={{
cursor: "pointer",
width: 130,
maxWidth: "100%",
flex: "0 0 auto",
}}
>
<option value="ALL">All Status</option>
{["RUNNING", "STOPPED", "STARTING", "ERROR"].map((status) => (
<option key={status} value={status}>
{status.charAt(0) + status.slice(1).toLowerCase()}
</option>
))}
</select>
<div className="ui-toolbar-actions">
<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={onDeploy}
>
<Plus size={12} /> Deploy
</button>
</div>
</div>
);
}
@@ -1,591 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Cpu, Database, HardDrive, Loader2, Network, X } from "lucide-react";
import {
containers as containersApi,
type Container,
type ContainerDetails,
} from "@/lib/api";
import ContainerStatCard from "../ContainerStatCard";
import StatusBadge from "../StatusBadge";
import { formatBytes } from "../../utils/container-utils";
interface ContainerDetailsModalProps {
container: Container;
onClose: () => void;
}
type DetailTab = "overview" | "logs" | "inspect" | "processes";
export default function ContainerDetailsModal({
container,
onClose,
}: ContainerDetailsModalProps) {
const [detail, setDetail] = useState<ContainerDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState<DetailTab>("overview");
const contentViewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
containersApi
.details(container.id, 300)
.then((response) => setDetail(response.data ?? null))
.catch((err: unknown) => {
setError(
err instanceof Error
? err.message
: "Failed to load container details",
);
})
.finally(() => setLoading(false));
}, [container.id]);
useEffect(() => {
if (loading || activeTab !== "logs") return;
const viewport = contentViewportRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
}, [activeTab, detail?.logs, loading]);
const tabs: Array<{ id: DetailTab; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "logs", label: "Logs" },
{ id: "inspect", label: "Inspect" },
{ id: "processes", label: "Processes" },
];
return (
<div className="modal-overlay">
<div className="modal-shell" style={{ maxWidth: 1080 }}>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="Close container details modal"
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
style={{
width: "100%",
maxWidth: 1080,
maxHeight: "90vh",
padding: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
padding: "18px 22px",
paddingRight: 56,
borderBottom: "1px solid var(--border)",
}}
>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<h3
style={{
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 16,
}}
>
{container.name}
</h3>
<StatusBadge
status={detail?.container.status ?? container.status}
/>
</div>
<p
style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 6 }}
>
{container.image}
{detail?.server
? ` • ${detail.server.name} (${detail.server.ip})`
: ""}
</p>
</div>
</div>
<div
style={{
display: "flex",
gap: 8,
padding: "14px 22px",
borderBottom: "1px solid var(--border)",
background: "rgba(255,255,255,0.02)",
flexWrap: "wrap",
}}
>
{tabs.map((tab) => (
<button
type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="btn btn-ghost"
style={{
fontSize: 12,
border:
activeTab === tab.id
? "1px solid rgba(59,130,246,0.35)"
: "1px solid transparent",
background:
activeTab === tab.id ? "rgba(59,130,246,0.12)" : undefined,
color: activeTab === tab.id ? "#3b82f6" : undefined,
}}
>
{tab.label}
</button>
))}
</div>
<div
ref={contentViewportRef}
style={{ flex: 1, overflow: "auto", padding: 22 }}
>
{loading ? (
<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 container details...
</p>
</div>
) : error ? (
<div
style={{
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 10,
padding: 16,
color: "#ef4444",
fontSize: 13,
}}
>
{error}
</div>
) : detail ? (
<>
{activeTab === "overview" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(min(220px, 100%), 1fr))",
gap: 12,
}}
>
<ContainerStatCard
icon={<Cpu size={15} />}
label="CPU Usage"
value={`${detail.stats.cpuPercent.toFixed(2)}%`}
subvalue={`Container processes: ${detail.stats.pids}`}
/>
<ContainerStatCard
icon={<HardDrive size={15} />}
label="Memory Usage"
value={detail.stats.memory.used || "—"}
subvalue={
detail.stats.memory.limit
? `of ${detail.stats.memory.limit} • ${detail.stats.memoryPercent.toFixed(2)}%`
: undefined
}
/>
<ContainerStatCard
icon={<Network size={15} />}
label="Network I/O"
value={detail.stats.network.raw || "—"}
subvalue={
detail.stats.network.totalBytes !== null &&
detail.stats.network.totalBytes !== undefined
? `Aggregate ${formatBytes(detail.stats.network.totalBytes)}`
: undefined
}
/>
<ContainerStatCard
icon={<Database size={15} />}
label="Block I/O"
value={detail.stats.io.raw || "—"}
subvalue={
detail.stats.io.totalBytes !== null &&
detail.stats.io.totalBytes !== undefined
? `Aggregate ${formatBytes(detail.stats.io.totalBytes)}`
: undefined
}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(min(380px, 100%), 1fr))",
gap: 16,
}}
>
<div
className="card"
style={{ padding: 16, background: "var(--bg-input)" }}
>
<h4
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 700,
marginBottom: 12,
}}
>
Memory
</h4>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
fontSize: 12,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Used</span>
<span>{detail.stats.memory.used || "—"}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Limit</span>
<span>{detail.stats.memory.limit || "—"}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Usage Percent</span>
<span>{detail.stats.memoryPercent.toFixed(2)}%</span>
</div>
</div>
</div>
<div
className="card"
style={{ padding: 16, background: "var(--bg-input)" }}
>
<h4
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 700,
marginBottom: 12,
}}
>
Runtime Identity
</h4>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
fontSize: 12,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Docker ID</span>
<span style={{ fontFamily: "monospace" }}>
{detail.container.dockerId || "—"}
</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Server</span>
<span>{detail.server.name}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>IP</span>
<span style={{ fontFamily: "monospace" }}>
{detail.server.ip}
</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Processes</span>
<span>{detail.processes.length}</span>
</div>
</div>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(min(380px, 100%), 1fr))",
gap: 16,
}}
>
<div
className="card"
style={{ padding: 16, background: "var(--bg-input)" }}
>
<h4
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 700,
marginBottom: 12,
}}
>
Network Usage
</h4>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
fontSize: 12,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Received</span>
<span>{detail.stats.network.read || "—"}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Transmitted</span>
<span>{detail.stats.network.write || "—"}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Aggregate</span>
<span>
{formatBytes(detail.stats.network.totalBytes)}
</span>
</div>
</div>
</div>
<div
className="card"
style={{ padding: 16, background: "var(--bg-input)" }}
>
<h4
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 700,
marginBottom: 12,
}}
>
Block I/O
</h4>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
fontSize: 12,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Read</span>
<span>{detail.stats.io.read || "—"}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Write</span>
<span>{detail.stats.io.write || "—"}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "var(--text-secondary)",
}}
>
<span>Aggregate</span>
<span>{formatBytes(detail.stats.io.totalBytes)}</span>
</div>
</div>
</div>
</div>
</div>
) : null}
{activeTab === "logs" ? (
<div
style={{
background: "#0d1117",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
padding: 16,
fontFamily: "JetBrains Mono, monospace",
fontSize: 12,
lineHeight: 1.6,
color: "#e6edf3",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
minHeight: 420,
}}
>
{detail.logs || "(no output)"}
</div>
) : null}
{activeTab === "inspect" ? (
<div
style={{
background: "#0d1117",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
padding: 16,
fontFamily: "JetBrains Mono, monospace",
fontSize: 12,
lineHeight: 1.6,
color: "#e6edf3",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
minHeight: 420,
}}
>
{JSON.stringify(detail.inspect, null, 2)}
</div>
) : null}
{activeTab === "processes" ? (
<div className="card" style={{ overflow: "hidden" }}>
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>PID</th>
<th>PPID</th>
<th>User</th>
<th>CPU</th>
<th>MEM</th>
<th>Elapsed</th>
<th>Command</th>
</tr>
</thead>
<tbody>
{detail.processes.length === 0 ? (
<tr>
<td
colSpan={7}
style={{
textAlign: "center",
color: "var(--text-muted)",
padding: 24,
}}
>
No process data available.
</td>
</tr>
) : (
detail.processes.map((process, index) => (
<tr key={`${process.pid}-${index}`}>
<td style={{ fontFamily: "monospace" }}>
{process.pid}
</td>
<td style={{ fontFamily: "monospace" }}>
{process.ppid}
</td>
<td>{process.user}</td>
<td>{process.cpu}</td>
<td>{process.memory}</td>
<td>{process.elapsed}</td>
<td
style={{
fontFamily: "monospace",
fontSize: 11,
}}
>
{process.command}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
</div>
);
}
@@ -1,118 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ScrollText, X } from "lucide-react";
import { containers as containersApi, type Container } from "@/lib/api";
import { sanitizeLogText } from "@/lib/terminal-output";
interface ContainerLogsModalProps {
container: Container;
onClose: () => void;
}
export default function ContainerLogsModal({
container,
onClose,
}: ContainerLogsModalProps) {
const [logs, setLogs] = useState("Loading...");
const [loading, setLoading] = useState(true);
const viewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
containersApi
.logs(container.id, 200)
.then((response) =>
setLogs(sanitizeLogText(response.data?.logs || "(no output)")),
)
.catch((err) =>
setLogs(
sanitizeLogText(
"Error: " + (err instanceof Error ? err.message : "Failed"),
),
),
)
.finally(() => setLoading(false));
}, [container.id]);
useEffect(() => {
if (loading) return;
const viewport = viewportRef.current;
if (!viewport) return;
const scrollFrame = window.requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight;
});
return () => window.cancelAnimationFrame(scrollFrame);
}, [logs, loading]);
return (
<div className="modal-overlay">
<div className="modal-shell" style={{ maxWidth: 760 }}>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="Close logs modal"
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
style={{
width: "100%",
maxWidth: 760,
padding: 0,
maxHeight: "85vh",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
padding: "16px 20px",
paddingRight: 52,
borderBottom: "1px solid var(--border)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ScrollText size={16} style={{ color: "var(--accent)" }} />
<h3
style={{
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 15,
}}
>
Logs - {container.name}
</h3>
</div>
</div>
<div
ref={viewportRef}
style={{
flex: 1,
overflowY: "auto",
padding: "16px 20px",
background: "#0d1117",
fontFamily: "JetBrains Mono, monospace",
fontSize: 12,
lineHeight: 1.6,
color: "#e6edf3",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{loading ? (
<span style={{ color: "#8b949e" }}>Loading...</span>
) : (
logs
)}
</div>
</div>
</div>
</div>
);
}
@@ -1,525 +0,0 @@
import TablePagination from "@/components/TablePagination";
import { useCallback, useState } from "react";
import {
Container as ContainerIcon,
ExternalLink,
Eye,
FolderOpen,
Loader2,
MoreVertical,
Play,
RotateCcw,
RefreshCw,
ScrollText,
Square,
Trash2,
} from "lucide-react";
import type { Container } from "@/lib/api";
import StatusBadge from "../StatusBadge";
function extractPublishedPort(portMapping: string): string | null {
const normalized = portMapping.trim();
if (!normalized) return null;
const publishedSegment = normalized.includes("->")
? normalized.split("->")[0]?.trim()
: normalized;
if (!publishedSegment) return null;
const match =
publishedSegment.match(/:(\d+)$/) ?? publishedSegment.match(/^(\d+)$/);
return match?.[1] ?? null;
}
function getContainerWebUiUrl(container: Container): string | null {
const serverIp = container.server?.ip?.trim();
if (!serverIp || !container.ports?.length) return null;
const publishedPort = container.ports
.map(extractPublishedPort)
.find((port): port is string => Boolean(port));
return publishedPort ? `http://${serverIp}:${publishedPort}` : null;
}
interface ContainersTableProps {
loading: boolean;
items: Container[];
paginatedItems: Container[];
currentPage: number;
totalPages: number;
totalItems: number;
startItem: number;
endItem: number;
acting: Record<string, boolean>;
openMenuId: string | null;
onPageChange: (page: number) => void;
onAction: (
id: string,
action: "start" | "stop" | "restart" | "delete",
) => void;
onOpenDetails: (container: Container) => void;
onOpenLogs: (container: Container) => void;
onOpenFileManager: (container: Container) => void;
onRebuild: (container: Container) => void;
onMenuToggle: (containerId: string) => void;
onMenuClose: () => void;
}
export default function ContainersTable({
loading,
items,
paginatedItems,
currentPage,
totalPages,
totalItems,
startItem,
endItem,
acting,
openMenuId,
onPageChange,
onAction,
onOpenDetails,
onOpenLogs,
onOpenFileManager,
onRebuild,
onMenuToggle,
onMenuClose,
}: ContainersTableProps) {
const [menuPosition, setMenuPosition] = useState<{
top: number;
left: number;
} | null>(null);
const handleMenuToggle = useCallback(
(event: React.MouseEvent<HTMLButtonElement>, containerId: string) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const menuWidth = 164;
const menuHeight = 112;
const nextLeft = Math.min(
window.innerWidth - menuWidth - 12,
Math.max(12, rect.right - menuWidth + rect.width),
);
const nextTop = Math.min(
window.innerHeight - menuHeight - 12,
rect.bottom + 6,
);
setMenuPosition({ top: Math.max(12, nextTop), left: nextLeft });
onMenuToggle(containerId);
},
[onMenuToggle],
);
return (
<div className="card" style={{ overflow: "hidden" }}>
{loading ? (
<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 containers...
</p>
</div>
) : items.length === 0 ? (
<div style={{ padding: 48, textAlign: "center" }}>
<ContainerIcon
size={36}
style={{
color: "var(--text-muted)",
margin: "0 auto 12px",
opacity: 0.4,
}}
/>
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
No containers found.
</p>
</div>
) : (
<>
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Container</th>
<th>Server</th>
<th>Status</th>
<th>Ports</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{paginatedItems.map((container) => (
<tr key={container.id}>
{(() => {
const webUiUrl = getContainerWebUiUrl(container);
const canRebuild =
container.sourceType === "APP_INSTALLER" ||
container.sourceType === "GIT_CLONE" ||
container.sourceType === "GIT_PROVIDER";
const rebuildDisabled =
!canRebuild || acting[container.id + "rebuild"];
const rebuildTitle = !canRebuild
? "Rebuild hanya tersedia untuk container App Installer atau Git/Repo"
: "Rebuild container";
return (
<>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 8,
background: "var(--bg-input)",
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ContainerIcon
size={14}
style={{ color: "#3b82f6" }}
/>
</div>
<div>
<p
style={{
color: "var(--text-primary)",
fontWeight: 600,
fontSize: 13,
}}
>
{container.name}
</p>
<p
style={{
color: "var(--text-muted)",
fontSize: 11,
fontFamily: "monospace",
}}
>
{container.image}
</p>
</div>
</div>
</td>
<td
style={{
fontSize: 12,
color: "var(--text-secondary)",
}}
>
{container.server?.name ?? "—"}
{container.server?.ip ? (
<>
<br />
<span
style={{
fontSize: 10,
color: "var(--text-muted)",
fontFamily: "monospace",
}}
>
{container.server.ip}
</span>
</>
) : null}
</td>
<td>
<StatusBadge status={container.status} />
</td>
<td>
{container.ports && container.ports.length > 0 ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
}}
>
{container.ports
.slice(0, 3)
.map((port, index) => (
<span
key={`${container.id}-${port}-${index}`}
style={{
background: "rgba(59,130,246,0.1)",
color: "#3b82f6",
padding: "1px 6px",
borderRadius: 4,
fontSize: 10,
fontFamily: "monospace",
}}
>
{port}
</span>
))}
{container.ports.length > 3 ? (
<span
style={{
fontSize: 10,
color: "var(--text-muted)",
}}
>
+{container.ports.length - 3}
</span>
) : null}
</div>
) : (
<span
style={{
color: "var(--text-muted)",
fontSize: 12,
}}
>
—
</span>
)}
</td>
<td>
<div
style={{
display: "flex",
gap: 6,
justifyContent: "flex-end",
}}
>
{webUiUrl ? (
<a
href={webUiUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
style={{ padding: "5px 8px" }}
title={`Open WebUI (${webUiUrl.replace("http://", "")})`}
aria-label={`Open WebUI ${webUiUrl.replace("http://", "")}`}
>
<ExternalLink size={11} />
</a>
) : (
<button
className="btn btn-ghost"
style={{
padding: "5px 8px",
opacity: 0.5,
cursor: "not-allowed",
}}
title="No published host port available"
disabled
>
<ExternalLink size={11} />
</button>
)}
{container.status === "STOPPED" ||
container.status === "ERROR" ? (
<button
title="Start"
onClick={() =>
onAction(container.id, "start")
}
className="btn btn-ghost"
style={{
padding: "4px 8px",
fontSize: 11,
color: "#10b981",
}}
disabled={acting[container.id + "start"]}
>
{acting[container.id + "start"] ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<Play size={12} />
)}
</button>
) : (
<button
title="Stop"
onClick={() => onAction(container.id, "stop")}
className="btn btn-ghost"
style={{ padding: "4px 8px", fontSize: 11 }}
disabled={acting[container.id + "stop"]}
>
{acting[container.id + "stop"] ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<Square size={12} />
)}
</button>
)}
<button
title="Restart"
onClick={() =>
onAction(container.id, "restart")
}
className="btn btn-ghost"
style={{ padding: "4px 8px", fontSize: 11 }}
disabled={acting[container.id + "restart"]}
>
{acting[container.id + "restart"] ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RotateCcw size={12} />
)}
</button>
<button
title="Details"
onClick={() => onOpenDetails(container)}
className="btn btn-ghost"
style={{ padding: "4px 8px", fontSize: 11 }}
>
<Eye size={12} />
</button>
<button
title="Logs"
onClick={() => {
onMenuClose();
onOpenLogs(container);
}}
className="btn btn-ghost"
style={{ padding: "4px 8px", fontSize: 11 }}
>
<ScrollText size={12} />
</button>
<div
style={{ position: "relative" }}
onClick={(event) => event.stopPropagation()}
>
<button
title="More actions"
onClick={(event) =>
handleMenuToggle(event, container.id)
}
className="btn btn-ghost"
style={{ padding: "4px 8px", fontSize: 11 }}
>
<MoreVertical size={12} />
</button>
{openMenuId === container.id && menuPosition ? (
<div
className="card"
onClick={(event) => event.stopPropagation()}
style={{
position: "fixed",
top: menuPosition.top,
left: menuPosition.left,
minWidth: 140,
padding: 6,
display: "flex",
flexDirection: "column",
gap: 4,
zIndex: 160,
boxShadow:
"0 18px 40px rgba(2, 6, 23, 0.42)",
}}
>
<button
onClick={() => {
onMenuClose();
onOpenFileManager(container);
}}
className="btn btn-ghost"
style={{
fontSize: 12,
justifyContent: "flex-start",
}}
disabled={container.status !== "RUNNING"}
>
<FolderOpen size={12} />
File Manager
</button>
<button
onClick={() => {
onMenuClose();
onRebuild(container);
}}
className="btn btn-ghost"
style={{
fontSize: 12,
justifyContent: "flex-start",
}}
title={rebuildTitle}
disabled={rebuildDisabled}
>
{acting[container.id + "rebuild"] ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<RefreshCw size={12} />
)}
Rebuild
</button>
<button
onClick={() => {
onMenuClose();
onAction(container.id, "delete");
}}
className="btn btn-ghost"
style={{
fontSize: 12,
justifyContent: "flex-start",
color: "#ef4444",
}}
disabled={acting[container.id + "delete"]}
>
{acting[container.id + "delete"] ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<Trash2 size={12} />
)}
Remove
</button>
</div>
) : null}
</div>
</div>
</td>
</>
);
})()}
</tr>
))}
</tbody>
</table>
</div>
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startItem={startItem}
endItem={endItem}
itemLabel="containers"
onPageChange={onPageChange}
/>
</>
)}
</div>
);
}
-596
View File
@@ -1,596 +0,0 @@
"use client";
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
import DashboardLayout from "@/components/DashboardLayout";
import ProcessLogsModal, {
type ProcessLogStep,
useProcessLogsModal,
} from "@/components/ProcessLogsModal";
import ToastViewport from "@/components/ToastViewport";
import { useCallback, useEffect, useState } from "react";
import {
containers as containersApi,
servers as serversApi,
type Container,
type Server,
} from "@/lib/api";
import {
readCachedPageData,
readStoredServerSelection,
storeServerSelection,
writeCachedPageData,
} from "@/lib/page-state";
import { useTablePagination } from "@/lib/use-table-pagination";
import { useToastManager } from "@/lib/use-toast-manager";
import ContainerDetailsModal from "./components/modals/ContainerDetailsModal";
import ContainerFileManagerModal from "./components/modals/ContainerFileManagerModal";
import ContainerLogsModal from "./components/modals/ContainerLogsModal";
import ContainersServerFilter from "./components/ContainersServerFilter";
import ContainersSummary from "./components/ContainersSummary";
import ContainersTable from "./components/tables/ContainersTable";
import ContainersToolbar from "./components/ContainersToolbar";
import DeployContainerModal from "./components/modals/DeployContainerModal";
const PAGE_KEY = "containers";
type PendingConfirmAction = {
title: string;
description: string;
confirmLabel: string;
tone?: "danger" | "warning" | "info";
note?: string;
onConfirm: () => void;
};
function createRebuildTimeline(activeStep: number): ProcessLogStep[] {
const steps = [
{ id: "prepare", label: "Preparing rebuild request", progress: 10 },
{
id: "rebuild",
label: "Waiting for rebuild process",
progress: "Running",
},
{ id: "sync", label: "Syncing container inventory", progress: 85 },
{ id: "complete", label: "Rebuild completed", progress: 100 },
];
return steps.map((step, index) => ({
...step,
status:
index < activeStep
? ("success" as const)
: index === activeStep
? ("running" as const)
: ("pending" as const),
}));
}
function createFailedRebuildTimeline(activeStep: number): ProcessLogStep[] {
return createRebuildTimeline(activeStep).map((step, index) =>
index === activeStep
? { ...step, status: "error" as const }
: index < activeStep
? { ...step, status: "success" as const }
: { ...step, status: "pending" as const },
);
}
export default function ContainersPage() {
const [data, setData] = useState<Container[]>([]);
const [serverList, setServerList] = useState<Server[]>([]);
const [selectedServerId, setSelectedServerId] = useState(() =>
readStoredServerSelection(PAGE_KEY),
);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [showDeploy, setShowDeploy] = useState(false);
const [logsFor, setLogsFor] = useState<Container | null>(null);
const [detailFor, setDetailFor] = useState<Container | null>(null);
const [fileManagerFor, setFileManagerFor] = useState<Container | null>(null);
const [acting, setActing] = useState<Record<string, boolean>>({});
const [syncing, setSyncing] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] =
useState<PendingConfirmAction | null>(null);
const { toasts, pushToast, dismissToast } = useToastManager();
const {
modalState: processLogsState,
isProcessLogsOpen,
openProcessLogs,
updateProcessLogs,
closeProcessLogs,
} = useProcessLogsModal();
const applyCachedState = useCallback((serverId: string) => {
const cached = readCachedPageData<{
items: Container[];
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;
silent?: boolean;
}) => {
if (!options?.silent) setLoading(true);
try {
const resolvedServerId = options?.serverId ?? "";
const serverResponse = await serversApi.list();
const nextServers = serverResponse.data ?? [];
let containerResponse;
if (options?.sync) {
try {
containerResponse = await containersApi.sync(
resolvedServerId ? { serverId: resolvedServerId } : undefined,
);
} catch {
containerResponse = await containersApi.list(
resolvedServerId ? { serverId: resolvedServerId } : undefined,
);
}
} else {
containerResponse = await containersApi.list(
resolvedServerId ? { serverId: resolvedServerId } : undefined,
);
}
const nextItems = containerResponse.data ?? [];
setData(nextItems);
setServerList(nextServers);
setSelectedServerId(resolvedServerId);
storeServerSelection(PAGE_KEY, resolvedServerId);
writeCachedPageData(
PAGE_KEY,
{
items: nextItems,
serverList: nextServers,
},
resolvedServerId,
);
} catch {
/* auth redirect handled */
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
const storedServerId = readStoredServerSelection(PAGE_KEY);
const frame = window.requestAnimationFrame(() => {
const hasCache = applyCachedState(storedServerId);
void load({
serverId: storedServerId,
silent: hasCache,
});
});
return () => window.cancelAnimationFrame(frame);
}, [applyCachedState, load]);
useEffect(() => {
const handleWindowClick = () => setOpenMenuId(null);
window.addEventListener("click", handleWindowClick);
return () => window.removeEventListener("click", handleWindowClick);
}, []);
const handleSync = async () => {
setSyncing(true);
try {
await load({ sync: true, serverId: selectedServerId });
} finally {
setSyncing(false);
}
};
const handleServerChange = async (serverId: string) => {
if (serverId === selectedServerId) return;
storeServerSelection(PAGE_KEY, serverId);
setSelectedServerId(serverId);
const hasCache = applyCachedState(serverId);
await load({ serverId, silent: hasCache });
};
const runAction = async (
id: string,
action: "start" | "stop" | "restart" | "delete",
) => {
setActing((current) => ({ ...current, [id + action]: true }));
try {
const response = await containersApi.action(
id,
action === "delete" ? "rm" : action,
);
if (action === "delete") {
setData((current) =>
current.filter((container) => container.id !== id),
);
} else if (response.data) {
setData((current) =>
current.map((container) =>
container.id === id
? { ...container, ...response.data }
: container,
),
);
} else {
const nextStatus =
action === "start"
? "RUNNING"
: action === "stop"
? "STOPPED"
: "STARTING";
setData((current) =>
current.map((container) =>
container.id === id
? { ...container, status: nextStatus as Container["status"] }
: container,
),
);
}
pushToast({
tone: "success",
title: "Action successful",
message:
action === "delete"
? "Container has been deleted."
: `Container has been ${action}ed.`,
duration: 3500,
showProgress: true,
});
} catch (err: unknown) {
pushToast({
tone: "error",
title: "Action failed",
message: err instanceof Error ? err.message : "Action failed",
duration: 6000,
showProgress: true,
});
} finally {
setActing((current) => ({ ...current, [id + action]: false }));
}
};
const runRebuild = async (container: Container) => {
setActing((current) => ({ ...current, [container.id + "rebuild"]: true }));
const baseTerminalLines = [
`[rebuild] Starting rebuild for ${container.name}`,
`[rebuild] Source: ${container.sourceType ?? "unknown"}`,
`[rebuild] Server ID: ${container.serverId}`,
];
let latestTerminalLines = [...baseTerminalLines];
const updateProcessTerminal = (
lines: string[],
patch: Parameters<typeof updateProcessLogs>[0] = {},
) => {
latestTerminalLines = lines;
updateProcessLogs({
terminalLogs: latestTerminalLines,
...patch,
});
};
openProcessLogs({
title: `Rebuild Logs - ${container.name}`,
description:
"Rebuild progress, inventory sync, and terminal-style output for this request.",
imageUrl: "/assets/images/img-chibi-fixing.png",
imageAlt: "Illustration of a character fixing something",
timelineLogs: createRebuildTimeline(0),
terminalLogs: latestTerminalLines,
initialTab: "timeline",
statusLabel: "Starting",
});
try {
latestTerminalLines = [
...baseTerminalLines,
"[rebuild] Creating backend rebuild job",
];
updateProcessLogs({
timelineLogs: createRebuildTimeline(1),
terminalLogs: latestTerminalLines,
statusLabel: "Starting",
});
const jobResponse = await containersApi.createRebuildJob(container.id);
const job = jobResponse.data;
let finalStatus = job.status;
let finalError = job.error;
updateProcessTerminal(
[
...latestTerminalLines,
`[job] Rebuild job created: ${job.id}`,
"[job] Streaming backend rebuild logs",
],
{
timelineLogs: createRebuildTimeline(1),
statusLabel: "Streaming",
},
);
await containersApi.streamJob(job.id, {
onLog: (entry) => {
updateProcessTerminal([...latestTerminalLines, entry.message], {
timelineLogs: createRebuildTimeline(1),
statusLabel: "Streaming",
});
},
onStatus: (nextJob) => {
finalStatus = nextJob.status;
finalError = nextJob.error;
},
});
if (finalStatus === "error") {
throw new Error(finalError || "Rebuild job failed");
}
updateProcessLogs({
timelineLogs: createRebuildTimeline(2),
terminalLogs: [
...latestTerminalLines,
"[rebuild] Rebuild command completed",
"[rebuild] Refreshing container inventory",
].filter(Boolean),
statusLabel: "Syncing",
});
await load({
sync: true,
serverId: selectedServerId,
silent: true,
});
updateProcessLogs({
timelineLogs: createRebuildTimeline(3).map((step) => ({
...step,
status: "success",
})),
terminalLogs: [
...latestTerminalLines,
"[rebuild] Rebuild command completed",
"[rebuild] Container inventory refreshed",
"[rebuild] Done",
].filter(Boolean),
statusLabel: "100%",
});
pushToast({
tone: "success",
title: "Rebuild scheduled",
message: `Container "${container.name}" berhasil direbuild.`,
duration: 4500,
showProgress: true,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Rebuild failed";
updateProcessLogs({
timelineLogs: createFailedRebuildTimeline(1),
terminalLogs: [
...baseTerminalLines,
"[rebuild] Rebuild failed",
`[error] ${message}`,
],
statusLabel: "Failed",
});
pushToast({
tone: "error",
title: "Rebuild failed",
message,
duration: 6000,
showProgress: true,
});
} finally {
setActing((current) => ({
...current,
[container.id + "rebuild"]: false,
}));
}
};
const handleAction = async (
id: string,
action: "start" | "stop" | "restart" | "delete",
) => {
if (action !== "delete") {
await runAction(id, action);
return;
}
const container = data.find((item) => item.id === id);
setConfirmDialog({
title: "Delete Container",
description: container
? `Delete container "${container.name}"?`
: "Delete this container?",
confirmLabel: "Delete Container",
tone: "danger",
note: "This removes the container from Docker on the selected server.",
onConfirm: () => {
void runAction(id, action);
},
});
};
const handleRebuild = (container: Container) => {
const isGitSource =
container.sourceType === "GIT_CLONE" ||
container.sourceType === "GIT_PROVIDER";
setConfirmDialog({
title: "Rebuild Container",
description: `Rebuild container "${container.name}" now?`,
confirmLabel: "Rebuild Container",
tone: "warning",
note: isGitSource
? "Doktainer will fetch the latest changes from the repository, rebuild the image if necessary, and then redeploy the container."
: "The container will be stopped, removed, and then restarted. Doktainer will attempt to pull the latest image first if available in the registry.",
onConfirm: () => {
void runRebuild(container);
},
});
};
const filtered = data.filter((container) => {
const matchesSearch =
!search ||
container.name.toLowerCase().includes(search.toLowerCase()) ||
container.image.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "ALL" || container.status === statusFilter;
return matchesSearch && matchesStatus;
});
const pagination = useTablePagination({
items: filtered,
resetKey: `${search}|${statusFilter}|${selectedServerId}`,
});
const stats = {
total: data.length,
running: data.filter((container) => container.status === "RUNNING").length,
stopped: data.filter((container) => container.status === "STOPPED").length,
paused: data.filter((container) => container.status === "PAUSED").length,
error: data.filter((container) => container.status === "ERROR").length,
};
return (
<DashboardLayout
title="Containers"
subtitle="Manage Docker containers 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"
/>
{showDeploy ? (
<DeployContainerModal
serverList={serverList}
onClose={() => setShowDeploy(false)}
onDeployed={load}
onProcessOpen={openProcessLogs}
onProcessUpdate={updateProcessLogs}
onToast={pushToast}
/>
) : null}
{logsFor ? (
<ContainerLogsModal
container={logsFor}
onClose={() => setLogsFor(null)}
/>
) : null}
{detailFor ? (
<ContainerDetailsModal
key={detailFor.id}
container={detailFor}
onClose={() => setDetailFor(null)}
/>
) : null}
{fileManagerFor ? (
<ContainerFileManagerModal
key={fileManagerFor.id}
container={fileManagerFor}
onClose={() => setFileManagerFor(null)}
/>
) : null}
<ProcessLogsModal
open={isProcessLogsOpen}
onClose={closeProcessLogs}
closeOnOverlayClick={false}
{...(processLogsState ?? {
title: "Process Logs",
timelineLogs: [],
terminalLogs: "",
})}
/>
<div
className="animate-slide-in"
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<ContainersSummary
total={stats.total}
running={stats.running}
stopped={stats.stopped}
pausedAndError={stats.paused + stats.error}
/>
<ContainersServerFilter
serverList={serverList}
selectedServerId={selectedServerId}
onChange={handleServerChange}
/>
<ContainersToolbar
search={search}
statusFilter={statusFilter}
syncing={syncing}
onSearchChange={setSearch}
onStatusFilterChange={setStatusFilter}
onSync={handleSync}
onDeploy={() => setShowDeploy(true)}
/>
<ContainersTable
loading={loading}
items={filtered}
paginatedItems={pagination.paginatedItems}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
startItem={pagination.startItem}
endItem={pagination.endItem}
acting={acting}
openMenuId={openMenuId}
onPageChange={pagination.setCurrentPage}
onAction={handleAction}
onOpenDetails={setDetailFor}
onOpenLogs={setLogsFor}
onOpenFileManager={setFileManagerFor}
onRebuild={handleRebuild}
onMenuToggle={(containerId) =>
setOpenMenuId((current) =>
current === containerId ? null : containerId,
)
}
onMenuClose={() => setOpenMenuId(null)}
/>
</div>
</DashboardLayout>
);
}
@@ -1,231 +0,0 @@
"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>
);
}
@@ -1,80 +0,0 @@
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>
);
}
@@ -1,72 +0,0 @@
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>
);
}
@@ -1,104 +0,0 @@
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>
);
}
@@ -1,58 +0,0 @@
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>
);
}
@@ -1,45 +0,0 @@
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
@@ -1,307 +0,0 @@
"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>
);
}
@@ -22,7 +22,7 @@ import {
type ProjectEnvironmentRecord,
type ProjectRecord,
} from "@/lib/api";
import ContainerFileManagerModal from "@/app/containers/components/modals/ContainerFileManagerModal";
import ContainerFileManagerModal from "@/components/containers/modals/ContainerFileManagerModal";
import AdvancedTabPanel from "./components/advanced/AdvancedTabPanel";
import DeploymentsTabPanel from "./components/deployments/DeploymentsTabPanel";
import EnvironmentTabPanel from "./components/environment/EnvironmentTabPanel";
@@ -18,8 +18,8 @@ import {
type ProjectRecord,
type Server,
} from "@/lib/api";
import DeployContainerModal from "@/app/containers/components/modals/DeployContainerModal";
import AddDatabaseModal from "@/app/databases/components/AddDatabaseModal";
import DeployContainerModal from "@/components/containers/modals/DeployContainerModal";
import AddDatabaseModal from "./components/database/AddDatabaseModal";
import EnvironmentContainersTable from "./components/containers/EnvironmentContainersTable";
import EnvironmentHeader from "./components/header/EnvironmentHeader";
import ImportFromSyncModal from "./components/import/ImportFromSyncModal";
@@ -35,7 +35,7 @@ import {
formatBytes,
formatTimestamp,
joinContainerPath,
} from "../../utils/container-utils";
} from "../utils/container-utils";
interface ContainerFileManagerModalProps {
container: Container;
-15
View File
@@ -4,8 +4,6 @@ import {
Bell,
Boxes,
CloudBackup,
Container,
Database,
FileText,
FolderArchive,
GitBranch,
@@ -71,19 +69,6 @@ export const navigation: NavSection[] = [
label: "Servers",
keywords: ["hosts", "vps", "instances", "ssh"],
},
// Temporarily hiding containers and databases until we have a more robust implementation and UI for these features. We want to avoid showing broken or incomplete features in the navigation.
/* {
href: "/containers",
icon: Container,
label: "Containers",
keywords: ["docker", "runtime"],
},
{
href: "/databases",
icon: Database,
label: "Databases",
keywords: ["mysql", "postgres", "mongodb", "redis"],
}, */
{
href: "/networks",
icon: Network,