diff --git a/src/app/containers/components/ContainerStatCard.tsx b/src/app/containers/components/ContainerStatCard.tsx deleted file mode 100644 index 3e9e5fe..0000000 --- a/src/app/containers/components/ContainerStatCard.tsx +++ /dev/null @@ -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 ( -
-
- - {label} - - {icon} -
-
- {value} -
- {subvalue ? ( -
- {subvalue} -
- ) : null} -
- ); -} \ No newline at end of file diff --git a/src/app/containers/components/ContainersServerFilter.tsx b/src/app/containers/components/ContainersServerFilter.tsx deleted file mode 100644 index f67e71e..0000000 --- a/src/app/containers/components/ContainersServerFilter.tsx +++ /dev/null @@ -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; -} - -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 ( -
-
- - - Server: - -
- - {serverList.map((server) => ( - - ))} -
-
-
- ); -} diff --git a/src/app/containers/components/ContainersSummary.tsx b/src/app/containers/components/ContainersSummary.tsx deleted file mode 100644 index fd56213..0000000 --- a/src/app/containers/components/ContainersSummary.tsx +++ /dev/null @@ -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 ( -
- {stats.map((stat) => ( -
-
-
-

- {stat.value} -

-

- {stat.label} -

-
-
- ))} -
- ); -} diff --git a/src/app/containers/components/ContainersToolbar.tsx b/src/app/containers/components/ContainersToolbar.tsx deleted file mode 100644 index bb08801..0000000 --- a/src/app/containers/components/ContainersToolbar.tsx +++ /dev/null @@ -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; - onDeploy: () => void; -} - -export default function ContainersToolbar({ - search, - statusFilter, - syncing, - onSearchChange, - onStatusFilterChange, - onSync, - onDeploy, -}: ContainersToolbarProps) { - return ( -
- onSearchChange(event.target.value)} - containerStyle={{ flex: "1 1 320px", minWidth: 220 }} - /> - -
- - -
-
- ); -} diff --git a/src/app/containers/components/modals/ContainerDetailsModal.tsx b/src/app/containers/components/modals/ContainerDetailsModal.tsx deleted file mode 100644 index e4925ea..0000000 --- a/src/app/containers/components/modals/ContainerDetailsModal.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [activeTab, setActiveTab] = useState("overview"); - const contentViewportRef = useRef(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 ( -
-
- -
-
-
-
-

- {container.name} -

- -
-

- {container.image} - {detail?.server - ? ` • ${detail.server.name} (${detail.server.ip})` - : ""} -

-
-
-
- {tabs.map((tab) => ( - - ))} -
- -
- {loading ? ( -
- -

- Loading container details... -

-
- ) : error ? ( -
- {error} -
- ) : detail ? ( - <> - {activeTab === "overview" ? ( -
-
- } - label="CPU Usage" - value={`${detail.stats.cpuPercent.toFixed(2)}%`} - subvalue={`Container processes: ${detail.stats.pids}`} - /> - } - label="Memory Usage" - value={detail.stats.memory.used || "—"} - subvalue={ - detail.stats.memory.limit - ? `of ${detail.stats.memory.limit} • ${detail.stats.memoryPercent.toFixed(2)}%` - : undefined - } - /> - } - 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 - } - /> - } - 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 - } - /> -
- -
-
-

- Memory -

-
-
- Used - {detail.stats.memory.used || "—"} -
-
- Limit - {detail.stats.memory.limit || "—"} -
-
- Usage Percent - {detail.stats.memoryPercent.toFixed(2)}% -
-
-
- -
-

- Runtime Identity -

-
-
- Docker ID - - {detail.container.dockerId || "—"} - -
-
- Server - {detail.server.name} -
-
- IP - - {detail.server.ip} - -
-
- Processes - {detail.processes.length} -
-
-
-
- -
-
-

- Network Usage -

-
-
- Received - {detail.stats.network.read || "—"} -
-
- Transmitted - {detail.stats.network.write || "—"} -
-
- Aggregate - - {formatBytes(detail.stats.network.totalBytes)} - -
-
-
- -
-

- Block I/O -

-
-
- Read - {detail.stats.io.read || "—"} -
-
- Write - {detail.stats.io.write || "—"} -
-
- Aggregate - {formatBytes(detail.stats.io.totalBytes)} -
-
-
-
-
- ) : null} - - {activeTab === "logs" ? ( -
- {detail.logs || "(no output)"} -
- ) : null} - - {activeTab === "inspect" ? ( -
- {JSON.stringify(detail.inspect, null, 2)} -
- ) : null} - - {activeTab === "processes" ? ( -
-
- - - - - - - - - - - - - - {detail.processes.length === 0 ? ( - - - - ) : ( - detail.processes.map((process, index) => ( - - - - - - - - - - )) - )} - -
PIDPPIDUserCPUMEMElapsedCommand
- No process data available. -
- {process.pid} - - {process.ppid} - {process.user}{process.cpu}{process.memory}{process.elapsed} - {process.command} -
-
-
- ) : null} - - ) : null} -
-
-
-
- ); -} diff --git a/src/app/containers/components/modals/ContainerLogsModal.tsx b/src/app/containers/components/modals/ContainerLogsModal.tsx deleted file mode 100644 index 757df3d..0000000 --- a/src/app/containers/components/modals/ContainerLogsModal.tsx +++ /dev/null @@ -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(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 ( -
-
- -
-
-
- -

- Logs - {container.name} -

-
-
-
- {loading ? ( - Loading... - ) : ( - logs - )} -
-
-
-
- ); -} diff --git a/src/app/containers/components/tables/ContainersTable.tsx b/src/app/containers/components/tables/ContainersTable.tsx deleted file mode 100644 index 85d5765..0000000 --- a/src/app/containers/components/tables/ContainersTable.tsx +++ /dev/null @@ -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; - 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, 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 ( -
- {loading ? ( -
- -

- Loading containers... -

-
- ) : items.length === 0 ? ( -
- -

- No containers found. -

-
- ) : ( - <> -
- - - - - - - - - - - - {paginatedItems.map((container) => ( - - {(() => { - 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 ( - <> - - - - - - - ); - })()} - - ))} - -
ContainerServerStatusPortsActions
-
-
- -
-
-

- {container.name} -

-

- {container.image} -

-
-
-
- {container.server?.name ?? "—"} - {container.server?.ip ? ( - <> -
- - {container.server.ip} - - - ) : null} -
- - - {container.ports && container.ports.length > 0 ? ( -
- {container.ports - .slice(0, 3) - .map((port, index) => ( - - {port} - - ))} - {container.ports.length > 3 ? ( - - +{container.ports.length - 3} - - ) : null} -
- ) : ( - - — - - )} -
-
- {webUiUrl ? ( - - - - ) : ( - - )} - {container.status === "STOPPED" || - container.status === "ERROR" ? ( - - ) : ( - - )} - - - -
event.stopPropagation()} - > - - {openMenuId === container.id && menuPosition ? ( -
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)", - }} - > - - - - - -
- ) : null} -
-
-
-
- - - )} -
- ); -} diff --git a/src/app/containers/page.tsx b/src/app/containers/page.tsx deleted file mode 100644 index e3924ea..0000000 --- a/src/app/containers/page.tsx +++ /dev/null @@ -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([]); - const [serverList, setServerList] = useState([]); - 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(null); - const [detailFor, setDetailFor] = useState(null); - const [fileManagerFor, setFileManagerFor] = useState(null); - const [acting, setActing] = useState>({}); - const [syncing, setSyncing] = useState(false); - const [openMenuId, setOpenMenuId] = useState(null); - const [confirmDialog, setConfirmDialog] = - useState(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[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 ( - - setConfirmDialog(null)} - onConfirm={() => { - const current = confirmDialog; - setConfirmDialog(null); - current?.onConfirm(); - }} - /> - - - {showDeploy ? ( - setShowDeploy(false)} - onDeployed={load} - onProcessOpen={openProcessLogs} - onProcessUpdate={updateProcessLogs} - onToast={pushToast} - /> - ) : null} - {logsFor ? ( - setLogsFor(null)} - /> - ) : null} - {detailFor ? ( - setDetailFor(null)} - /> - ) : null} - {fileManagerFor ? ( - setFileManagerFor(null)} - /> - ) : null} - - -
- - - setShowDeploy(true)} - /> - - setOpenMenuId((current) => - current === containerId ? null : containerId, - ) - } - onMenuClose={() => setOpenMenuId(null)} - /> -
-
- ); -} diff --git a/src/app/databases/components/DatabaseGrid.tsx b/src/app/databases/components/DatabaseGrid.tsx deleted file mode 100644 index 8f4e255..0000000 --- a/src/app/databases/components/DatabaseGrid.tsx +++ /dev/null @@ -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 ( -
- {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 ( -
-
-
-
-
- -
-
-

- {db.name} -

-

- {label} - {db.exposedPort ? ` · :${db.exposedPort}` : ""} -

-
-
- - {isRunning && ( - - )} - {db.status.charAt(0) + db.status.slice(1).toLowerCase()} - -
- -
- {[ - { label: "Server", value: db.server?.name ?? "—" }, - { label: "Port", value: db.exposedPort ?? "—" }, - { label: "Container", value: db.name ?? "—" }, - { label: "Image", value: db.image }, - ].map((m) => ( -
-

- {m.label} -

-

- {m.value} -

-
- ))} -
- -
- {isRunning ? ( - - ) : ( - - )} - -
-
- ); - })} -
- ); -} diff --git a/src/app/databases/components/DatabaseServerFilter.tsx b/src/app/databases/components/DatabaseServerFilter.tsx deleted file mode 100644 index 4f64e32..0000000 --- a/src/app/databases/components/DatabaseServerFilter.tsx +++ /dev/null @@ -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; -} - -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 ( -
-
- - - Server: - -
- - {serverList.map((server) => ( - - ))} -
-
- {/*

- {selectedServerId - ? "Sync database container only for the selected server" - : "Default mode: show all databases from all servers"} -

*/} -
- ); -} diff --git a/src/app/databases/components/DatabaseStatePanel.tsx b/src/app/databases/components/DatabaseStatePanel.tsx deleted file mode 100644 index 6443e0a..0000000 --- a/src/app/databases/components/DatabaseStatePanel.tsx +++ /dev/null @@ -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 ( -
-
- -

- Loading Databases... -

-
-
- ); - } - - if (!isEmpty) { - return null; - } - - return ( -
- -

- {searchActive - ? "No database containers match your search" - : "No database containers detected on connected Docker hosts"} -

- -
- ); -} diff --git a/src/app/databases/components/DatabaseSummary.tsx b/src/app/databases/components/DatabaseSummary.tsx deleted file mode 100644 index 5e8cd81..0000000 --- a/src/app/databases/components/DatabaseSummary.tsx +++ /dev/null @@ -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 ( -
- {stats.map((s) => { - const Icon = s.icon; - return ( -
-
- -
-
-

- {s.value} -

-

- {s.label} -

-
-
- ); - })} -
- ); -} diff --git a/src/app/databases/components/DatabaseToolbar.tsx b/src/app/databases/components/DatabaseToolbar.tsx deleted file mode 100644 index d01be78..0000000 --- a/src/app/databases/components/DatabaseToolbar.tsx +++ /dev/null @@ -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; - onAdd: () => void; -} - -export default function DatabaseToolbar({ - search, - syncing, - onSearchChange, - onSync, - onAdd, -}: DatabaseToolbarProps) { - return ( -
- onSearchChange(event.target.value)} - containerStyle={{ flex: "1 1 360px", minWidth: 240 }} - /> -
- - -
-
- ); -} diff --git a/src/app/databases/components/database-utils.ts b/src/app/databases/components/database-utils.ts deleted file mode 100644 index c10f778..0000000 --- a/src/app/databases/components/database-utils.ts +++ /dev/null @@ -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), - }; -} diff --git a/src/app/databases/page.tsx b/src/app/databases/page.tsx deleted file mode 100644 index 1b8f7ad..0000000 --- a/src/app/databases/page.tsx +++ /dev/null @@ -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([]); - const [serverList, setServerList] = useState([]); - const [selectedServerId, setSelectedServerId] = useState(""); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(""); - const [showAdd, setShowAdd] = useState(false); - const [actioning, setActioning] = useState(null); - const [removing, setRemoving] = useState(null); - const [syncing, setSyncing] = useState(false); - const [confirmDialog, setConfirmDialog] = - useState(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 ( - - setConfirmDialog(null)} - onConfirm={() => { - const current = confirmDialog; - setConfirmDialog(null); - current?.onConfirm(); - }} - /> - - {showAdd && ( - setShowAdd(false)} - onAdded={() => load({ sync: true, serverId: selectedServerId })} - /> - )} -
- d.serverId)).size} - typeCount={new Set(data.map((d) => d.databaseType)).size} - /> - - setShowAdd(true)} - /> - setShowAdd(true)} - /> - {/* Cards */} - {!loading && filteredData.length > 0 && ( - - )} -
-
- ); -} diff --git a/src/app/databases/components/AddDatabaseModal.tsx b/src/app/projects/[projectId]/environments/[environmentId]/components/database/AddDatabaseModal.tsx similarity index 100% rename from src/app/databases/components/AddDatabaseModal.tsx rename to src/app/projects/[projectId]/environments/[environmentId]/components/database/AddDatabaseModal.tsx diff --git a/src/app/databases/components/database-constants.ts b/src/app/projects/[projectId]/environments/[environmentId]/components/database/database-constants.ts similarity index 100% rename from src/app/databases/components/database-constants.ts rename to src/app/projects/[projectId]/environments/[environmentId]/components/database/database-constants.ts diff --git a/src/app/databases/components/database-types.ts b/src/app/projects/[projectId]/environments/[environmentId]/components/database/database-types.ts similarity index 100% rename from src/app/databases/components/database-types.ts rename to src/app/projects/[projectId]/environments/[environmentId]/components/database/database-types.ts diff --git a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx index c925a3d..bbc0b7c 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx +++ b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx @@ -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"; diff --git a/src/app/projects/[projectId]/environments/[environmentId]/page.tsx b/src/app/projects/[projectId]/environments/[environmentId]/page.tsx index 3c83a4d..17bed2e 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/page.tsx +++ b/src/app/projects/[projectId]/environments/[environmentId]/page.tsx @@ -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"; diff --git a/src/app/containers/components/StatusBadge.tsx b/src/components/containers/StatusBadge.tsx similarity index 100% rename from src/app/containers/components/StatusBadge.tsx rename to src/components/containers/StatusBadge.tsx diff --git a/src/app/containers/components/modals/ContainerFileManagerModal.tsx b/src/components/containers/modals/ContainerFileManagerModal.tsx similarity index 99% rename from src/app/containers/components/modals/ContainerFileManagerModal.tsx rename to src/components/containers/modals/ContainerFileManagerModal.tsx index 5db8a09..f34702f 100644 --- a/src/app/containers/components/modals/ContainerFileManagerModal.tsx +++ b/src/components/containers/modals/ContainerFileManagerModal.tsx @@ -35,7 +35,7 @@ import { formatBytes, formatTimestamp, joinContainerPath, -} from "../../utils/container-utils"; +} from "../utils/container-utils"; interface ContainerFileManagerModalProps { container: Container; diff --git a/src/app/containers/components/modals/DeployContainerModal.tsx b/src/components/containers/modals/DeployContainerModal.tsx similarity index 100% rename from src/app/containers/components/modals/DeployContainerModal.tsx rename to src/components/containers/modals/DeployContainerModal.tsx diff --git a/src/app/containers/components/modals/DeployTimelineModal.tsx b/src/components/containers/modals/DeployTimelineModal.tsx similarity index 100% rename from src/app/containers/components/modals/DeployTimelineModal.tsx rename to src/components/containers/modals/DeployTimelineModal.tsx diff --git a/src/app/containers/utils/container-utils.ts b/src/components/containers/utils/container-utils.ts similarity index 100% rename from src/app/containers/utils/container-utils.ts rename to src/components/containers/utils/container-utils.ts diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 03f6e0e..5b55a74 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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,