refactor(ui): consolidate container and database management under projects
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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";
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -35,7 +35,7 @@ import {
|
||||
formatBytes,
|
||||
formatTimestamp,
|
||||
joinContainerPath,
|
||||
} from "../../utils/container-utils";
|
||||
} from "../utils/container-utils";
|
||||
|
||||
interface ContainerFileManagerModalProps {
|
||||
container: Container;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user