feat(deployment): add deployment history and rollback support
This commit is contained in:
1 parent
029b25b596
commit
4e9b173edb
36 files changed
+3663
-569
No files matched your search
+272
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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>
|
||||
|
||||
+187
-277
@@ -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>
|
||||
);
|
||||
}
|
||||
+60
-117
@@ -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,
|
||||
};
|
||||
}
|
||||
+134
-2
@@ -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}
|
||||
|
||||
+1
-24
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user