Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m31s
Build App (Preview) / build-windows (pull_request) Successful in 6m24s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
A project can be out of date and say nothing about it, in two ways that
compound: the lineage lookup treats "unknown" as an answer, and the
fallback that exists for unknown lineage disappears when its probe fails.
`create_container` always writes triple-c.base-image-id, even when the
value is unknown — deliberately, so an inherited image label cannot ride
a snapshot forever. That makes Some("") the ordinary reading from a
container whose lineage was never established. The lookup filtered for
emptiness only on the final result, so that empty string satisfied the
container branch and skipped the snapshot entirely: a snapshot that had
recorded a real lineage was never consulted, and the project reported
"unknown" with the answer one lookup away. Each source is now filtered
before it can answer, in pick_recorded_lineage, which is a plain function
so the case has a test that fails against the old logic.
A genuinely pre-label project stays unknown, and should: its ancestor is
not knowable, and inventing one would make it look permanently current.
The probe is the intended signal for those — but if the probe failed,
get_container_staleness returned early with nothing populated, the banner
found no gaps and rendered null, and the probe_error it already knew how
to display sat behind a gate that returned before reaching it. Silence
there is indistinguishable from "up to date", and it is likeliest for the
oldest and largest projects, whose manifests are the ones apt to exceed
the inspection limit — one real project measured 6.93 MB against an 8 MB
cap. An unknown-lineage container whose probe failed now says the check
could not be completed, with the reason, under the tone that means
unresolved rather than the one that means something is wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
9.8 KiB
TypeScript
259 lines
9.8 KiB
TypeScript
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
|
import Button from "../../ui/Button";
|
|
import StatusIndicator from "../../ui/StatusIndicator";
|
|
import MigrationReportCard from "../MigrationReportCard";
|
|
import MigrationInterruptedCard from "../MigrationInterruptedCard";
|
|
import { formatSnapshotDate, joinFeatures } from "../migrationCopy";
|
|
|
|
interface Props {
|
|
migration: ContainerMigration;
|
|
/** Migration mirrors Reset's gate: the container has to be stopped. */
|
|
canMigrate: boolean;
|
|
onOpen: () => void;
|
|
}
|
|
|
|
const SHELL =
|
|
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
|
|
|
|
/**
|
|
* The Overview answer to "why is this container behaving oddly?".
|
|
*
|
|
* It leads with the *features* that are missing, not image digests: a user does
|
|
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
|
|
* that host-browser opening and the auth bridge do not work. Digests are the
|
|
* evidence, not the message.
|
|
*
|
|
* It also has to survive the run: an in-flight migration, an interrupted one,
|
|
* and the report are all shown here, because the modal is dismissable and the
|
|
* outcome must not vanish with it.
|
|
*/
|
|
export default function ContainerMigrationBanner({
|
|
migration,
|
|
canMigrate,
|
|
onOpen,
|
|
}: Props) {
|
|
const {
|
|
staleness,
|
|
probing,
|
|
probeSettled,
|
|
running,
|
|
recovered,
|
|
interrupted,
|
|
report,
|
|
phaseMessage,
|
|
busy,
|
|
} = migration;
|
|
|
|
// An unfinished migration outranks its own report. The report's action row
|
|
// offers Keep, and Keep on a mid-swap container drops the rollback image
|
|
// while `:latest` still points at the old lineage — the backend's message on
|
|
// the very same record says to resume. Resume is the only honest primary
|
|
// action here, so the report card is not rendered at all.
|
|
if (interrupted) {
|
|
return (
|
|
<section
|
|
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
|
|
aria-label="Container base update was interrupted"
|
|
>
|
|
<MigrationInterruptedCard
|
|
record={interrupted}
|
|
busy={busy || running}
|
|
onResume={() => void migration.resume()}
|
|
onRollback={() => void migration.rollback()}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// The report outranks staleness: after a run, the outcome is the news.
|
|
if (report) {
|
|
return (
|
|
<section
|
|
className={`${SHELL} ${
|
|
report.phase === "partial" || report.phase === "failed"
|
|
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
|
|
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
|
|
}`}
|
|
aria-label="Container base update result"
|
|
>
|
|
<MigrationReportCard
|
|
report={report}
|
|
busy={busy}
|
|
onKeep={() => void migration.keep()}
|
|
onRollback={() => void migration.rollback()}
|
|
onDismiss={() => void migration.dismiss()}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (running) {
|
|
return (
|
|
<section
|
|
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
|
aria-label="Container base update in progress"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<StatusIndicator
|
|
tone="busy"
|
|
label={
|
|
recovered
|
|
? "A container base update was already running"
|
|
: "Updating container base"
|
|
}
|
|
className="text-[13px] font-semibold"
|
|
/>
|
|
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
|
{phaseMessage ?? "Starting…"}
|
|
</p>
|
|
{recovered && (
|
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
|
It was still in progress when the app last closed. Picking it back up.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button size="md" onClick={onOpen}>
|
|
Show progress
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!staleness) return null;
|
|
|
|
// `stale` is deliberately false whenever `known` is false — an unestablished
|
|
// lineage is not a claim of staleness. But a container with no base-image
|
|
// label is exactly the old container most likely to be missing things, and
|
|
// the probe says so directly. So the probe's own findings are grounds to
|
|
// speak up even though the version comparison never happened.
|
|
const probeFoundGaps =
|
|
!staleness.known &&
|
|
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
|
|
|
// The probe is the *only* signal a container with no lineage label has, so
|
|
// when it fails there is nothing left to be quiet about. Staying silent here
|
|
// is indistinguishable from "everything is fine" — and it is the likeliest
|
|
// outcome for the oldest, largest projects, whose manifests are the ones apt
|
|
// to exceed the inspection limit. Say that the check did not run instead.
|
|
const probeUnavailable = !staleness.known && !!staleness.probe_error;
|
|
|
|
if (!staleness.stale && !probeFoundGaps && !probeUnavailable) return null;
|
|
|
|
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
|
const features = joinFeatures(staleness.missing_features);
|
|
|
|
return (
|
|
<section
|
|
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
|
aria-label={
|
|
probeUnavailable
|
|
? "Container base could not be checked"
|
|
: "Container base is out of date"
|
|
}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<StatusIndicator
|
|
// A check that could not run is not a finding: it gets the
|
|
// "unresolved" tone rather than the one that says something is
|
|
// wrong with the container.
|
|
tone={probeUnavailable ? "unknown" : "error"}
|
|
label={
|
|
staleness.known
|
|
? "Container base is out of date"
|
|
: probeUnavailable
|
|
? "Container base could not be checked"
|
|
: "Container is missing things the current base ships"
|
|
}
|
|
className="text-[13px] font-semibold"
|
|
/>
|
|
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
|
{staleness.known
|
|
? snapshot
|
|
? `Running on a saved image from ${snapshot}.`
|
|
: "Running on a saved image older than the current base."
|
|
: probeUnavailable
|
|
? "This container predates base-image tracking, so probing it is the only way to tell whether it is behind — and that did not complete."
|
|
: "This container predates base-image tracking, so it was probed directly."}
|
|
</p>
|
|
|
|
{staleness.missing_features.length > 0 && (
|
|
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
|
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
|
<span className="text-[var(--text-secondary)]">{features}.</span>
|
|
</p>
|
|
)}
|
|
|
|
{staleness.missing_features.length === 0 &&
|
|
staleness.missing_paths.length > 0 && (
|
|
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
|
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
|
<span className="font-mono text-[var(--text-secondary)]">
|
|
{staleness.missing_paths.join(", ")}
|
|
</span>
|
|
</p>
|
|
)}
|
|
|
|
{/* Deliberately "differ" rather than "behind": the count is a drift
|
|
measure, not a promise that every one of them is newer. */}
|
|
{staleness.outdated_package_count > 0 && (
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
|
{staleness.outdated_package_count} package
|
|
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
|
|
versions on the current base, where security updates land.
|
|
</p>
|
|
)}
|
|
|
|
{staleness.probe_error && (
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
|
Some checks did not complete: {staleness.probe_error}
|
|
</p>
|
|
)}
|
|
|
|
{/* An out-of-date container that also has data under /var is the one
|
|
case where updating can cost something, so it is said here and not
|
|
only behind the button. */}
|
|
{staleness.unpreserved_data.length > 0 && (
|
|
<p className="text-xs text-[var(--text-primary)] leading-snug">
|
|
Not carried across:{" "}
|
|
<span className="font-mono text-[var(--text-secondary)]">
|
|
{staleness.unpreserved_data.map((d) => d.path).join(", ")}
|
|
</span>
|
|
<span className="text-[var(--text-secondary)]">
|
|
{" "}
|
|
— back this up before updating.
|
|
</span>
|
|
</p>
|
|
)}
|
|
|
|
{!canMigrate && (
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
|
{/* Distinguishing these matters: "stop the container" on a
|
|
container that is already stopped, because the probe has not
|
|
landed, reads as a bug. */}
|
|
{!probeSettled
|
|
? probing
|
|
? "Checking what this container has that the current base does not…"
|
|
: "That check did not complete, so what would be carried across is not known. Updating stays disabled until it does — try again once the container can be inspected."
|
|
: "Stop the container to update its base."}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
size="md"
|
|
variant="primary"
|
|
disabled={!canMigrate}
|
|
onClick={onOpen}
|
|
className="flex-shrink-0"
|
|
>
|
|
Update container base…
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|