feat(deployment): add deployment history and rollback support

This commit is contained in:
KodekaTeam committed 2026-07-17 00:27:07 +07:00
1 parent 029b25b596
commit 4e9b173edb
36 files changed
+3663 -569

No files matched your search

@@ -0,0 +1,272 @@
"use client";
import { AlertTriangle, History, X } from "lucide-react";
import type { DeploymentRecord } from "@/lib/api";
interface DeploymentDetailsModalProps {
deployment: DeploymentRecord | null;
loading: boolean;
error: string | null;
onClose: () => void;
}
function formatDate(value: string | null) {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
}
function getStatusClass(status?: DeploymentRecord["status"]) {
if (status === "SUCCESS") return "badge-online";
if (status === "FAILED") return "badge-danger";
return "badge-warning";
}
export default function DeploymentDetailsModal({
deployment,
loading,
error,
onClose,
}: DeploymentDetailsModalProps) {
if (!deployment && !loading && !error) return null;
const metadata = deployment
? [
["Status", deployment.status],
["Trigger", deployment.trigger],
["Commit", deployment.commitSha ?? "-"],
["Branch", deployment.branch ?? "-"],
["Started", formatDate(deployment.startedAt)],
["Completed", formatDate(deployment.completedAt)],
["Actor", deployment.user?.name ?? "System"],
["Image digest", deployment.imageDigest ?? "-"],
]
: [];
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-shell"
style={{ maxWidth: 820 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="modal-close"
onClick={onClose}
aria-label="Close deployment details"
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
role="dialog"
aria-modal="true"
aria-labelledby="deployment-details-title"
style={{
width: "100%",
maxWidth: "none",
padding: 24,
display: "grid",
gap: 18,
}}
>
<header
style={{
display: "flex",
alignItems: "center",
gap: 12,
paddingRight: 28,
}}
>
<span
style={{
width: 36,
height: 36,
flex: "0 0 36px",
display: "grid",
placeItems: "center",
borderRadius: 10,
color: "var(--accent-blue)",
background: "rgba(59,130,246,0.1)",
border: "1px solid rgba(59,130,246,0.18)",
}}
>
<History size={18} />
</span>
<div style={{ minWidth: 0 }}>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 11,
fontWeight: 600,
}}
>
Deployment details
</p>
<h2
id="deployment-details-title"
style={{
margin: "5px 0 0",
color: "var(--text-primary)",
fontSize: 18,
lineHeight: 1.3,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={deployment?.version ?? "Loading deployment..."}
>
{deployment?.version ?? "Loading deployment..."}
</h2>
</div>
</header>
{loading ? (
<div
style={{
padding: 20,
color: "var(--text-muted)",
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
}}
>
Loading deployment details...
</div>
) : error ? (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "14px 16px",
color: "var(--text-danger)",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.22)",
borderRadius: 10,
fontSize: 13,
lineHeight: 1.6,
}}
>
<AlertTriangle size={16} style={{ flex: "0 0 auto", marginTop: 2 }} />
<span>{error}</span>
</div>
) : deployment ? (
<>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(150px, 100%), 1fr))",
gap: 8,
}}
>
{metadata.map(([label, value]) => (
<div
key={label}
style={{
minWidth: 0,
padding: "10px 12px",
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
}}
>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 600,
}}
>
{label}
</p>
{label === "Status" ? (
<span
className={`ui-badge ${getStatusClass(deployment.status)}`}
style={{ marginTop: 6 }}
>
{value}
</span>
) : (
<p
style={{
margin: "6px 0 0",
color: "var(--text-primary)",
fontSize: 12,
fontFamily:
label === "Commit" || label === "Image digest"
? "var(--font--code)"
: undefined,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={value}
>
{value}
</p>
)}
</div>
))}
</div>
{deployment.error ? (
<div
style={{
padding: "12px 14px",
color: "var(--text-danger)",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.22)",
borderRadius: 8,
fontSize: 12,
lineHeight: 1.6,
overflowWrap: "anywhere",
}}
>
{deployment.error}
</div>
) : null}
<section style={{ minWidth: 0 }}>
<p
style={{
margin: "0 0 8px",
color: "var(--text-muted)",
fontSize: 11,
fontWeight: 600,
}}
>
Sanitized configuration snapshot
</p>
<pre
style={{
maxHeight: 340,
margin: 0,
padding: 14,
overflow: "auto",
border: "1px solid var(--border)",
borderRadius: 8,
background: "var(--bg-input)",
color: "var(--text-secondary)",
fontSize: 11,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
>
{JSON.stringify(deployment.configSnapshot ?? {}, null, 2)}
</pre>
</section>
</>
) : null}
</div>
</div>
</div>
);
}
@@ -20,6 +20,7 @@ export default function DeploymentSummaryCard({
className="card"
style={{
padding: 14,
minWidth: 0,
minHeight: 104,
display: "flex",
flexDirection: "column",
@@ -42,11 +43,24 @@ export default function DeploymentSummaryCard({
color: "var(--text-primary)",
fontSize: 20,
lineHeight: 1.1,
minWidth: 0,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={item.value}
>
{item.value}
</strong>
<span style={{ color: toneColor[item.tone], fontSize: 11 }}>
<span
style={{
color: toneColor[item.tone],
fontSize: 11,
minWidth: 0,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={item.subvalue}
>
{item.subvalue}
</span>
</section>
@@ -2,19 +2,10 @@
import TablePagination from "@/components/TablePagination";
import { useTablePagination } from "@/lib/use-table-pagination";
import {
CheckCircle2,
Circle,
Clock3,
ExternalLink,
GitBranch,
GitCommit,
Rocket,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Eye, GitBranch, GitCommit, Loader2, RotateCcw } from "lucide-react";
import type {
DeploymentHistoryItem,
DeploymentPipelineStep,
DeploymentTabData,
} from "../../types/app-detail-types";
import PanelShell from "../overview/PanelShell";
@@ -22,6 +13,9 @@ import DeploymentSummaryCard from "./DeploymentSummaryCard";
interface DeploymentsTabPanelProps {
deployments: DeploymentTabData;
onRollback: (deploymentId: string) => void;
onViewDetails: (deploymentId: string) => void;
rollingBackId?: string | null;
}
const statusClass: Record<DeploymentHistoryItem["status"], string> = {
@@ -31,27 +25,29 @@ const statusClass: Record<DeploymentHistoryItem["status"], string> = {
"Rolled Back": "badge-warning",
};
function getStepIcon(status: DeploymentPipelineStep["status"]) {
if (status === "success") return <CheckCircle2 size={15} />;
if (status === "failed") return <XCircle size={15} />;
if (status === "running") return <Clock3 size={15} />;
return <Circle size={15} />;
}
function getStepColor(status: DeploymentPipelineStep["status"]) {
if (status === "success") return "var(--accent-green)";
if (status === "failed") return "var(--text-danger)";
if (status === "running") return "var(--accent-blue)";
return "var(--text-muted)";
}
export default function DeploymentsTabPanel({
deployments,
onRollback,
onViewDetails,
rollingBackId = null,
}: DeploymentsTabPanelProps) {
const [statusFilter, setStatusFilter] = useState<
"ALL" | DeploymentHistoryItem["status"]
>("ALL");
const [triggerFilter, setTriggerFilter] = useState("ALL");
const filteredHistory = useMemo(
() =>
deployments.history.filter(
(item) =>
(statusFilter === "ALL" || item.status === statusFilter) &&
(triggerFilter === "ALL" || item.trigger === triggerFilter),
),
[deployments.history, statusFilter, triggerFilter],
);
const pagination = useTablePagination({
items: deployments.history,
items: filteredHistory,
pageSize: 5,
resetKey: deployments.history.map((item) => item.id).join("|"),
resetKey: `${statusFilter}:${triggerFilter}:${filteredHistory.map((item) => item.id).join("|")}`,
});
return (
@@ -69,184 +65,120 @@ export default function DeploymentsTabPanel({
))}
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 0.8fr)",
gap: 12,
alignItems: "start",
}}
>
<PanelShell title="Latest Deployment">
<div
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr)",
minHeight: "160px",
maxHeight: "160px",
gap: 12,
alignItems: "start",
padding: 12,
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-input)",
}}
>
<span
style={{
width: 42,
height: 42,
borderRadius: 10,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--accent-blue)",
background: "rgba(59,130,246,0.1)",
}}
>
<Rocket size={21} />
</span>
<div style={{ minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: 16,
}}
>
{deployments.latest.version}
</h3>
<span
className={`ui-badge ${statusClass[deployments.latest.status]}`}
>
{deployments.latest.status}
</span>
</div>
<p
style={{
marginTop: 6,
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
}}
>
Deployed by {deployments.latest.deployedBy} from{" "}
{deployments.latest.source}. This panel reflects the current
container deployment record and runtime inspection state.
</p>
<p
style={{
marginTop: 8,
color: "var(--text-secondary)",
fontFamily: "var(--font--code)",
fontSize: 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{deployments.latest.image}
</p>
</div>
</div>
</PanelShell>
<PanelShell title="Deployment Artifacts">
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(150px, 100%), 1fr))",
gap: 8,
}}
>
{deployments.artifacts.map((artifact) => (
<div
key={artifact.label}
style={{
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-input)",
padding: "9px 10px",
minWidth: 0,
}}
>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 700,
}}
>
{artifact.label}
</p>
<p
style={{
marginTop: 5,
color: "var(--text-primary)",
fontSize: 12,
fontFamily: "var(--font--code)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{artifact.value}
</p>
<p
style={{
marginTop: 3,
color: "var(--text-muted)",
fontSize: 10,
}}
>
{artifact.meta}
</p>
</div>
))}
</div>
</PanelShell>
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 0.8fr)",
gap: 12,
alignItems: "start",
}}
>
<PanelShell
title={`Deployment History (${deployments.history.length})`}
<PanelShell title={`Deployment History (${filteredHistory.length})`}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "flex-start",
gap: 12,
flexWrap: "wrap",
marginBottom: 14,
}}
>
<div style={{ overflowX: "auto" }}>
<table className="data-table" style={{ minWidth: 760 }}>
<thead>
<label
style={{
display: "grid",
gap: 4,
minWidth: 160,
flex: "0 1 190px",
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
<select
className="input"
style={{ width: "100%", minHeight: 36, fontSize: 12 }}
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value as typeof statusFilter)
}
aria-label="Filter deployments by status"
>
<option value="ALL">All statuses</option>
<option value="Success">Success</option>
<option value="Failed">Failed</option>
<option value="Running">Running</option>
<option value="Rolled Back">Rolled Back</option>
</select>
</label>
<label
style={{
display: "grid",
gap: 4,
minWidth: 160,
flex: "0 1 190px",
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
<select
className="input"
style={{ width: "100%", minHeight: 36, fontSize: 12 }}
value={triggerFilter}
onChange={(event) => setTriggerFilter(event.target.value)}
aria-label="Filter deployments by trigger"
>
<option value="ALL">All triggers</option>
{[
...new Set(deployments.history.map((item) => item.trigger)),
].map((trigger) => (
<option key={trigger} value={trigger}>
{trigger}
</option>
))}
</select>
</label>
</div>
<div style={{ overflowX: "auto" }}>
<table className="data-table" style={{ minWidth: 900 }}>
<thead>
<tr>
<th>Version</th>
<th>Status</th>
<th>Trigger</th>
<th>Commit</th>
<th>Branch</th>
<th>Duration</th>
<th>Deployed At</th>
<th style={{ textAlign: "right" }}>Action</th>
</tr>
</thead>
<tbody>
{pagination.paginatedItems.length === 0 ? (
<tr>
<th>Version</th>
<th>Status</th>
<th>Trigger</th>
<th>Commit</th>
<th>Branch</th>
<th>Duration</th>
<th>Deployed At</th>
<td
colSpan={8}
style={{
padding: 24,
textAlign: "center",
color: "var(--text-muted)",
}}
>
{deployments.history.length === 0
? "No deployment history yet."
: "No deployments match the selected filters."}
</td>
</tr>
</thead>
<tbody>
{pagination.paginatedItems.map((deployment) => (
) : (
pagination.paginatedItems.map((deployment) => (
<tr key={deployment.id}>
<td style={{ fontFamily: "var(--font--code)" }}>
<td
style={{
maxWidth: 220,
fontFamily: "var(--font--code)",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={deployment.version}
>
{deployment.version}
</td>
<td>
@@ -267,83 +199,61 @@ export default function DeploymentsTabPanel({
</td>
<td>{deployment.duration}</td>
<td>{deployment.deployedAt}</td>
<td>
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<button
type="button"
className="btn btn-ghost btn-small"
onClick={() => onViewDetails(deployment.id)}
title="View deployment details"
aria-label={`View details for deployment ${deployment.version}`}
style={{ padding: "6px 8px", minWidth: 30 }}
>
<Eye size={13} />
</button>
{deployment.canRollback ? (
<button
type="button"
className="btn btn-ghost btn-small"
onClick={() => onRollback(deployment.id)}
disabled={rollingBackId !== null}
title="Rollback to this deployment"
aria-label={`Rollback to deployment ${deployment.version}`}
style={{ padding: "6px 8px", minWidth: 30 }}
>
{rollingBackId === deployment.id ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RotateCcw size={13} />
)}
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<TablePagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
startItem={pagination.startItem}
endItem={pagination.endItem}
itemLabel="deployments"
onPageChange={pagination.setCurrentPage}
/>
</PanelShell>
<PanelShell title="Pipeline Timeline">
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{deployments.pipeline.map((step) => (
<div
key={step.id}
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr) auto",
gap: 10,
alignItems: "start",
padding: "9px 10px",
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-input)",
}}
>
<span
style={{
color: getStepColor(step.status),
display: "inline-flex",
marginTop: 1,
}}
>
{getStepIcon(step.status)}
</span>
<div style={{ minWidth: 0 }}>
<p
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: 12,
fontWeight: 700,
}}
>
{step.label}
</p>
<p
style={{
marginTop: 3,
color: "var(--text-muted)",
fontSize: 11,
lineHeight: 1.45,
}}
>
{step.description}
</p>
</div>
<span
style={{
color: "var(--text-muted)",
fontSize: 11,
fontFamily: "var(--font--code)",
}}
>
{step.duration}
</span>
</div>
))}
</div>
</PanelShell>
</div>
))
)}
</tbody>
</table>
</div>
<TablePagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
startItem={pagination.startItem}
endItem={pagination.endItem}
itemLabel="deployments"
onPageChange={pagination.setCurrentPage}
/>
</PanelShell>
</section>
);
}
@@ -1,4 +1,8 @@
import type { Container, ContainerDetails } from "@/lib/api";
import type {
Container,
ContainerDetails,
DeploymentRecord,
} from "@/lib/api";
import type {
DeploymentHistoryItem,
DeploymentTabData,
@@ -21,70 +25,71 @@ function getDeploymentStatus(
return "Rolled Back";
}
function getDuration(detail: ContainerDetails | null) {
const startedAt = detail?.inspect.State;
if (
typeof startedAt === "object" &&
startedAt !== null &&
"StartedAt" in startedAt &&
typeof startedAt.StartedAt === "string"
) {
const startedDate = new Date(startedAt.StartedAt);
if (!Number.isNaN(startedDate.getTime())) return "Runtime active";
}
return "-";
}
function getCommit(container: Container) {
if (container.sourceType === "GIT_CLONE") return "Git source";
if (container.sourceType === "GIT_PROVIDER") return "Provider";
return "-";
}
function getBranch(container: Container) {
if (
container.sourceType === "GIT_CLONE" ||
container.sourceType === "GIT_PROVIDER"
) {
return "Configured source";
}
return "-";
}
function getImageTag(image: string) {
if (!image.trim()) return "-";
if (image.includes("@")) return "digest";
const normalized = image.trim();
if (!normalized) return "-";
const lastSegment = image.split("/").at(-1) ?? image;
const digest = normalized.includes("@")
? normalized.split("@").at(-1)
: normalized;
const digestValue = digest ?? "";
if (/^sha256:[a-f0-9]{64}$/i.test(digestValue)) {
return `sha256:${digestValue.slice(7, 19)}…`;
}
const lastSegment = normalized.split("/").at(-1) ?? normalized;
const tagIndex = lastSegment.lastIndexOf(":");
return tagIndex >= 0 ? lastSegment.slice(tagIndex + 1) || "latest" : "latest";
}
function formatDeploymentDuration(item: DeploymentRecord) {
if (!item.startedAt || !item.completedAt) return "-";
const durationMs = new Date(item.completedAt).getTime() - new Date(item.startedAt).getTime();
if (!Number.isFinite(durationMs) || durationMs < 0) return "-";
return `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`;
}
function formatDeploymentStatus(
status: DeploymentRecord["status"],
): DeploymentHistoryItem["status"] {
if (status === "SUCCESS") return "Success";
if (status === "FAILED") return "Failed";
if (status === "RUNNING" || status === "QUEUED") return "Running";
return status === "ROLLED_BACK" ? "Rolled Back" : "Failed";
}
function formatDeploymentTrigger(trigger: DeploymentRecord["trigger"]) {
return {
MANUAL: "Manual deploy",
GIT_WEBHOOK: "Git webhook",
REBUILD: "Rebuild",
ROLLBACK: "Rollback",
APP_INSTALLER: "App installer",
}[trigger];
}
function mapDeploymentHistory(items: DeploymentRecord[]) {
return items.map((item) => ({
id: item.id,
version: item.version || item.image || item.id,
status: formatDeploymentStatus(item.status),
trigger: formatDeploymentTrigger(item.trigger),
commit: item.commitSha || "-",
branch: item.branch || "-",
duration: formatDeploymentDuration(item),
deployedAt: formatDate(item.createdAt),
canRollback: item.status === "SUCCESS" && Boolean(item.image),
}));
}
export function createDeploymentsData(
container: Container,
detail: ContainerDetails | null,
deploymentRecords: DeploymentRecord[] = [],
): DeploymentTabData {
const status = getDeploymentStatus(container);
const history: DeploymentHistoryItem[] = [
{
id: container.id,
version: container.name,
status,
trigger:
container.sourceType === "APP_INSTALLER"
? "App installer"
: container.sourceType === "MANUAL"
? "Manual deploy"
: "Git deploy",
commit: getCommit(container),
branch: getBranch(container),
duration: getDuration(detail),
deployedAt: formatDate(container.createdAt),
},
];
const history = mapDeploymentHistory(deploymentRecords);
const latest = history[0];
const runtimeLoaded = detail !== null;
const isSuccessful = status === "Success";
@@ -93,8 +98,8 @@ export function createDeploymentsData(
summaries: [
{
label: "Latest Deployment",
value: latest.version,
subvalue: `${latest.status} - ${latest.deployedAt}`,
value: latest?.version ?? "No deployments",
subvalue: latest ? `${latest.status} - ${latest.deployedAt}` : "No history yet",
tone: isSuccessful ? "green" : "amber",
},
{
@@ -116,68 +121,6 @@ export function createDeploymentsData(
tone: runtimeLoaded ? "blue" : "amber",
},
],
latest: {
version: latest.version,
status: latest.status,
source: container.sourceType ?? "MANUAL",
image: container.image,
deployedBy: "-",
},
history,
pipeline: [
{
id: "source",
label: "Source metadata",
description: `${container.sourceType ?? "MANUAL"} deployment metadata loaded from the container record.`,
status: "success",
duration: "-",
},
{
id: "image",
label: "Image resolved",
description: `Runtime image resolved as ${container.image}.`,
status: container.image ? "success" : "failed",
duration: "-",
},
{
id: "runtime",
label: "Runtime inspected",
description: runtimeLoaded
? "Docker inspect and runtime statistics are available."
: "Runtime inspect is unavailable, usually because the container is stopped or Docker returned an error.",
status: runtimeLoaded ? "success" : "pending",
duration: "-",
},
{
id: "health",
label: "Container status",
description: `Current container status is ${container.status}.`,
status: isSuccessful ? "success" : status === "Failed" ? "failed" : "pending",
duration: "-",
},
],
artifacts: [
{
label: "Image",
value: container.image,
meta: "Runtime image",
},
{
label: "Deploy Mode",
value: container.deployMode ?? "IMAGE",
meta: "Deployment strategy",
},
{
label: "Restart Policy",
value: container.restartPolicy || "unless-stopped",
meta: "Docker policy",
},
{
label: "Server",
value: detail?.server.name ?? container.server?.name ?? container.serverId,
meta: detail?.server.ip ?? container.server?.ip ?? "Target host",
},
],
hasHistoricalData: false,
};
}
@@ -18,6 +18,7 @@ import {
type ContainerProcess,
type ContainerProjectEnvFile,
type ContainerRuntimeStats,
type DeploymentRecord,
type Domain,
type ProjectEnvironmentRecord,
type ProjectRecord,
@@ -25,6 +26,7 @@ import {
import ContainerFileManagerModal from "@/components/containers/modals/ContainerFileManagerModal";
import AdvancedTabPanel from "./components/advanced/AdvancedTabPanel";
import DeploymentsTabPanel from "./components/deployments/DeploymentsTabPanel";
import DeploymentDetailsModal from "./components/deployments/DeploymentDetailsModal";
import EnvironmentTabPanel from "./components/environment/EnvironmentTabPanel";
import AppDetailHeader from "./components/header/AppDetailHeader";
import LogsTabPanel from "./components/logs/LogsTabPanel";
@@ -467,6 +469,16 @@ export default function AppContainerDetailPage() {
);
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState(false);
const [rollingBackDeploymentId, setRollingBackDeploymentId] = useState<
string | null
>(null);
const [selectedDeployment, setSelectedDeployment] =
useState<DeploymentRecord | null>(null);
const [deploymentDetailLoading, setDeploymentDetailLoading] =
useState(false);
const [deploymentDetailError, setDeploymentDetailError] = useState<
string | null
>(null);
const [activeAction, setActiveAction] = useState<AppAction["id"] | null>(
null,
);
@@ -491,6 +503,7 @@ export default function AppContainerDetailPage() {
const runtimeMetricsInFlightRef = useRef(false);
const runtimeDetailRef = useRef<ContainerDetails | null>(null);
const environmentRecordRef = useRef<ProjectEnvironmentRecord | null>(null);
const deploymentHistoryRef = useRef<DeploymentRecord[]>([]);
const projectEnvRef = useRef<ContainerProjectEnvFile | null>(null);
const domainsLoadedRef = useRef(false);
const domainsLoadingRef = useRef(false);
@@ -518,6 +531,7 @@ export default function AppContainerDetailPage() {
runtimeDetailLoadedRef.current = false;
projectEnvLoadedRef.current = false;
logsLoadedRef.current = false;
deploymentHistoryRef.current = [];
try {
const [projectResponse, containerResponse, metricsResult] =
@@ -614,6 +628,105 @@ export default function AppContainerDetailPage() {
containerRecordRef.current = containerRecord;
}, [containerRecord]);
const hydrateDeployments = useCallback(async () => {
const container = containerRecordRef.current;
if (!container) return;
try {
const response = await containersApi.deployments(params.containerId, {
page: 1,
pageSize: 50,
});
deploymentHistoryRef.current = response.data.items;
setAppDetail((current) =>
current
? {
...current,
deployments: createDeploymentsData(
container,
runtimeDetailRef.current,
response.data.items,
),
}
: current,
);
} catch {
// The detail page remains usable if deployment history is unavailable.
}
}, [params.containerId]);
const runRollback = useCallback(
async (deploymentId: string) => {
if (acting || rollingBackDeploymentId) return;
setRollingBackDeploymentId(deploymentId);
setActionError("");
try {
await containersApi.rollbackDeployment(params.containerId, deploymentId);
await load();
setActiveTab("deployments");
} catch (rollbackError) {
setActionError(
rollbackError instanceof Error
? rollbackError.message
: "Rollback failed",
);
} finally {
setRollingBackDeploymentId(null);
}
},
[acting, load, params.containerId, rollingBackDeploymentId],
);
const requestRollback = useCallback(
(deploymentId: string) => {
const deployment = appDetail?.deployments.history.find(
(item) => item.id === deploymentId,
);
if (!deployment || !deployment.canRollback) return;
setConfirmDialog({
title: "Rollback Deployment",
description: `Rollback container "${appDetail?.name ?? "this container"}" to ${deployment.version}?`,
confirmLabel: "Rollback Deployment",
tone: "warning",
note: "The current container will be replaced using the selected deployment artifact. Doktainer will attempt to recover the current runtime if rollback fails.",
onConfirm: () => {
void runRollback(deploymentId);
},
});
},
[appDetail, runRollback],
);
const openDeploymentDetails = useCallback(
async (deploymentId: string) => {
setSelectedDeployment(null);
setDeploymentDetailError(null);
setDeploymentDetailLoading(true);
try {
const response = await containersApi.deployment(
params.containerId,
deploymentId,
);
setSelectedDeployment(response.data);
} catch (detailsError) {
setDeploymentDetailError(
detailsError instanceof Error
? detailsError.message
: "Failed to load deployment details",
);
} finally {
setDeploymentDetailLoading(false);
}
},
[params.containerId],
);
useEffect(() => {
if (containerRecord) void hydrateDeployments();
}, [containerRecord, hydrateDeployments]);
const refreshRuntimeMetrics = useCallback(async () => {
const container = containerRecordRef.current;
if (!container || container.status !== "RUNNING") return;
@@ -757,7 +870,11 @@ export default function AppContainerDetailPage() {
serverIp: detail.server.ip,
metrics: buildMetrics(detail, container),
runtime: createRuntimeData(container, detail),
deployments: createDeploymentsData(container, detail),
deployments: createDeploymentsData(
container,
detail,
deploymentHistoryRef.current,
),
advanced: createAdvancedData(container, detail),
environment: createEnvironmentData({
container,
@@ -940,6 +1057,7 @@ export default function AppContainerDetailPage() {
return;
}
setActionError("");
window.open(appDetail.openUrl, "_blank", "noopener,noreferrer");
}, [appDetail]);
@@ -1432,6 +1550,15 @@ export default function AppContainerDetailPage() {
current?.onConfirm();
}}
/>
<DeploymentDetailsModal
deployment={selectedDeployment}
loading={deploymentDetailLoading}
error={deploymentDetailError}
onClose={() => {
setSelectedDeployment(null);
setDeploymentDetailError(null);
}}
/>
<ProcessLogsModal
open={isProcessLogsOpen}
onClose={closeProcessLogs}
@@ -1544,7 +1671,12 @@ export default function AppContainerDetailPage() {
/>
) : activeTab === "terminal" ? null : activeTab ===
"deployments" ? (
<DeploymentsTabPanel deployments={appDetail.deployments} />
<DeploymentsTabPanel
deployments={appDetail.deployments}
onRollback={requestRollback}
onViewDetails={openDeploymentDetails}
rollingBackId={rollingBackDeploymentId}
/>
) : activeTab === "advanced" ? (
<AdvancedTabPanel
advanced={appDetail.advanced}
@@ -144,35 +144,12 @@ export interface DeploymentHistoryItem {
branch: string;
duration: string;
deployedAt: string;
}
export interface DeploymentPipelineStep {
id: string;
label: string;
description: string;
status: "success" | "running" | "pending" | "failed";
duration: string;
}
export interface DeploymentArtifact {
label: string;
value: string;
meta: string;
canRollback?: boolean;
}
export interface DeploymentTabData {
summaries: DeploymentTabSummary[];
latest: {
version: string;
status: DeploymentHistoryItem["status"];
source: string;
image: string;
deployedBy: string;
};
history: DeploymentHistoryItem[];
pipeline: DeploymentPipelineStep[];
artifacts: DeploymentArtifact[];
hasHistoricalData: boolean;
}
export interface AdvancedSummary {