Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,20 @@
<div className="flex items-center gap-3 mb-1">
<p className="text-slate-900 truncate">{deployment.name}</p>
{getStatusBadge(deployment.status)}
{/* B6 expiry icon — only when warning/expired. Tooltip via
native title attribute keeps the dependency footprint
tiny on a hot list. */}
{/* B6/#180 expiry icon — only when approaching expiry.
Yellow ≤3 months out, red ≤6 weeks out, red octagon
once expired. Tooltip via native title attribute keeps
the dependency footprint tiny on a hot list. */}
{expiryState === 'warning' && (
<span title={`Läuft am ${expiryDateLabel} ab`}>
<AlertTriangle className="w-4 h-4 text-amber-500" />
</span>
)}
{expiryState === 'critical' && (
<span title={`Läuft bald ab — am ${expiryDateLabel}`}>
<AlertTriangle className="w-4 h-4 text-red-500" />
</span>
)}
{expiryState === 'expired' && (
<span title="Wird in Kürze automatisch gelöscht">
<AlertOctagon className="w-4 h-4 text-red-500" />
Expand Down
32 changes: 22 additions & 10 deletions src/pages/DeploymentDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -733,11 +733,20 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep
</div>
</div>

{/* Expiry banner */}
{expiryState !== "ok" && deployment.expires_at && (
{/* Expiry banner (#180): red theme once expiry is imminent (≤6 weeks,
"critical") or past ("expired"), amber while merely approaching
(≤3 months, "warning"). */}
{expiryState !== "ok" && deployment.expires_at && (() => {
const isRed = expiryState === "critical" || expiryState === "expired";
const expiryDateLabel = new Date(deployment.expires_at).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return (
<Card
className={
expiryState === "expired"
isRed
? "border-red-200 shadow-sm bg-gradient-to-br from-red-50 to-white"
: "border-amber-200 shadow-sm bg-gradient-to-br from-amber-50 to-white"
}
Expand All @@ -746,29 +755,31 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep
<div className="flex items-start gap-4">
<div
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${
expiryState === "expired" ? "bg-red-100" : "bg-amber-100"
isRed ? "bg-red-100" : "bg-amber-100"
}`}
>
{expiryState === "expired" ? (
<AlertOctagon className="w-6 h-6 text-red-600" />
) : (
<AlertTriangle className="w-6 h-6 text-amber-600" />
<AlertTriangle className={isRed ? "w-6 h-6 text-red-600" : "w-6 h-6 text-amber-600"} />
)}
</div>
<div className="flex-1">
<h3 className={expiryState === "expired" ? "text-red-900 mb-2" : "text-amber-900 mb-2"}>
<h3 className={isRed ? "text-red-900 mb-2" : "text-amber-900 mb-2"}>
{expiryState === "expired"
? "Dieses Deployment ist abgelaufen und wird in Kürze gelöscht."
: `Läuft am ${new Date(deployment.expires_at).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })} ab`}
: expiryState === "critical"
? `Läuft bald ab — am ${expiryDateLabel}`
: `Läuft am ${expiryDateLabel} ab`}
</h3>
<p className={expiryState === "expired" ? "text-sm text-red-700 mb-3" : "text-sm text-amber-700 mb-3"}>
<p className={isRed ? "text-sm text-red-700 mb-3" : "text-sm text-amber-700 mb-3"}>
{expiryState === "expired"
? "Verlängern Sie es jetzt, falls die nächtliche Bereinigung noch nicht gelaufen ist."
: "Verlängern Sie es, bevor der Cleanup-Job es entfernt."}
</p>
<Button
size="sm"
variant={expiryState === "expired" ? "destructive" : "default"}
variant={isRed ? "destructive" : "default"}
disabled={extendInFlight}
onClick={() => setExtendDialogOpen(true)}
>
Expand All @@ -785,7 +796,8 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep
</div>
</CardContent>
</Card>
)}
);
})()}

{/* Progress for active deployments */}
{deployment.status === 'deploying' && (
Expand Down
41 changes: 27 additions & 14 deletions src/utils/deployment.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import type { DeploymentDto } from "../api/deployments";

/**
* Lifecycle state of a deployment based on its `expires_at` and
* `expiry_warning_at` timestamps (set by the backend at create-time).
* Lifecycle state of a deployment based on how much runtime is left before its
* `expires_at` timestamp (set by the backend at create-/extend-time).
*
* - "ok" : not expiring soon — no banner, no icon
* - "warning" : within the warning window (e.g. ≤14 days before expiry)
* - "expired" : past expires_at, will be hard-deleted by the next Beat sweep
* - "ok" : more than 3 months left — no banner, no icon
* - "warning" : within 3 months of expiry (yellow) — expiry approaching
* - "critical" : within 6 weeks of expiry (red) — expiry imminent
* - "expired" : past `expires_at`, will be hard-deleted by the next Beat sweep
*
* Legacy deployments without expiry timestamps stay "ok" forever so the
* UI never warns about something the backend isn't actually tracking.
* The 6-week / 3-month windows (issue #180) are fixed frontend constants for
* now. The issue discussion raised whether these durations should instead come
* from the backend — if that happens, replace the constants below with values
* read off the DTO. Whether every extension is actually accepted is a separate
* concern tracked in #184.
*
* Legacy deployments without an `expires_at` stay "ok" forever so the UI never
* warns about something the backend isn't actually tracking.
*/
export type ExpiryState = "ok" | "warning" | "expired";
export type ExpiryState = "ok" | "warning" | "critical" | "expired";

// Pre-expiry thresholds, expressed in ms. Days-based (not calendar months) to
// mirror the backend's DAYS_PER_MONTH=30 approximation in deployment_expiry.py.
const SIX_WEEKS_MS = 42 * 24 * 60 * 60 * 1000; // "critical" — red
const THREE_MONTHS_MS = 90 * 24 * 60 * 60 * 1000; // "warning" — yellow

export function getExpiryState(
deployment: Pick<DeploymentDto, "expires_at" | "expiry_warning_at">
deployment: Pick<DeploymentDto, "expires_at">
): ExpiryState {
if (!deployment.expires_at || !deployment.expiry_warning_at) return "ok";
const now = Date.now();
if (!deployment.expires_at) return "ok";
const expiresAtMs = new Date(deployment.expires_at).getTime();
const warnAtMs = new Date(deployment.expiry_warning_at).getTime();
if (now >= expiresAtMs) return "expired";
if (now >= warnAtMs) return "warning";
if (Number.isNaN(expiresAtMs)) return "ok";
const remaining = expiresAtMs - Date.now();
if (remaining <= 0) return "expired";
if (remaining <= SIX_WEEKS_MS) return "critical";
if (remaining <= THREE_MONTHS_MS) return "warning";
return "ok";
}
Loading