@@ -989,7 +1023,7 @@ function DetailRow({
{value}
diff --git a/src/lib/__tests__/cache-time.test.ts b/src/lib/__tests__/cache-time.test.ts
new file mode 100644
index 00000000..b2bf1a41
--- /dev/null
+++ b/src/lib/__tests__/cache-time.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from "vitest";
+import { formatRelativeTimestamp, formatCacheExpiry } from "../cache-time";
+
+// Reference Date used as the "now" anchor in every case below.
+// Chosen to be UTC noon so timezone shifts don't bleed into the unit
+// boundaries of the test inputs.
+const NOW = new Date("2026-06-01T12:00:00Z");
+
+describe("formatRelativeTimestamp", () => {
+ it("returns 'now' / 'this minute' for the same instant", () => {
+ // Intl.RelativeTimeFormat with numeric='auto' renders 0-magnitude
+ // values as 'now' or 'this minute' depending on the unit it picked.
+ // Both are acceptable; we just need a non-empty short string.
+ const result = formatRelativeTimestamp("2026-06-01T12:00:00Z", NOW);
+ expect(result.length).toBeGreaterThan(0);
+ expect(result).not.toContain("NaN");
+ });
+
+ it("renders future timestamps with positive direction", () => {
+ // 4 hours into the future -> 'in 4 hours'
+ const result = formatRelativeTimestamp("2026-06-01T16:00:00Z", NOW);
+ expect(result).toMatch(/in 4 hours?/i);
+ });
+
+ it("renders past timestamps with negative direction", () => {
+ // 4 hours into the past -> '4 hours ago'
+ const result = formatRelativeTimestamp("2026-06-01T08:00:00Z", NOW);
+ expect(result).toMatch(/4 hours? ago/i);
+ });
+
+ it("picks the largest sensible unit (days, not 24 hours)", () => {
+ // 36 hours into the future -> 'in 2 days' (rounded), not 'in 36 hours'.
+ // Pinning the rollover so a future tweak that switches the unit
+ // selection logic to e.g. 'always show hours under a week' doesn't
+ // silently regress the compactness contract the issue body promises.
+ const result = formatRelativeTimestamp("2026-06-03T00:00:00Z", NOW);
+ expect(result).toMatch(/days?/i);
+ expect(result).not.toMatch(/hours/i);
+ });
+
+ it("falls back to the input string for an unparseable timestamp", () => {
+ expect(formatRelativeTimestamp("not-a-date", NOW)).toBe("not-a-date");
+ });
+});
+
+describe("formatCacheExpiry", () => {
+ it("renders future timestamps with the relative-time framing", () => {
+ // For a not-yet-expired entry we just want the bare relative-time
+ // output -- no 'expired ... ago' wrapper.
+ const result = formatCacheExpiry("2026-06-01T16:00:00Z", NOW);
+ expect(result).toMatch(/in 4 hours?/i);
+ expect(result).not.toContain("expired");
+ });
+
+ it("renders past timestamps with the 'expired ..., will re-fetch' framing", () => {
+ // 12 minutes ago -- exactly the framing the issue body promised:
+ // 'expired 12 minutes ago, will re-fetch on next download'.
+ const result = formatCacheExpiry("2026-06-01T11:48:00Z", NOW);
+ expect(result).toMatch(/^expired/i);
+ expect(result).toContain("will re-fetch on next download");
+ });
+
+ it("treats the exact same instant as expired", () => {
+ // t == now should fall on the 'expired' side -- once a TTL boundary
+ // hits, the next request will re-fetch, so showing 'expires in 0
+ // seconds' would be misleading.
+ const result = formatCacheExpiry("2026-06-01T12:00:00Z", NOW);
+ expect(result).toMatch(/^expired/i);
+ expect(result).toContain("will re-fetch on next download");
+ });
+
+ it("falls back to the input string for an unparseable timestamp", () => {
+ expect(formatCacheExpiry("not-a-date", NOW)).toBe("not-a-date");
+ });
+});
diff --git a/src/lib/api/__tests__/artifacts.test.ts b/src/lib/api/__tests__/artifacts.test.ts
index 1b5a43f3..c8127fe3 100644
--- a/src/lib/api/__tests__/artifacts.test.ts
+++ b/src/lib/api/__tests__/artifacts.test.ts
@@ -43,6 +43,111 @@ describe("artifactsApi", () => {
);
});
+ // -------------------------------------------------------------------------
+ // adaptArtifact: cache metadata fields (#449 / artifact-keeper#1541)
+ //
+ // The backend optionally returns `cache_cached_at` and `cache_expires_at`
+ // on the per-artifact metadata response. Until the SDK regenerates with
+ // those fields they ride through `adaptArtifact` via a runtime cast --
+ // these tests pin that the cast actually plumbs them, including the
+ // null / missing edge cases that the `skip_serializing_if = "Option::is_none"`
+ // backend shape produces.
+ // -------------------------------------------------------------------------
+
+ it("plumbs cache_cached_at / cache_expires_at through adaptArtifact when present", async () => {
+ const items = [
+ {
+ id: "a1",
+ repository_key: "pypi-remote",
+ path: "requests/requests-2.31.0-py3-none-any.whl",
+ name: "requests-2.31.0-py3-none-any.whl",
+ version: "2.31.0",
+ size_bytes: 62500,
+ checksum_sha256: "deadbeef",
+ content_type: "application/octet-stream",
+ download_count: 0,
+ created_at: "2026-06-01T10:00:00Z",
+ cache_cached_at: "2026-06-01T10:00:00Z",
+ cache_expires_at: "2026-06-02T10:00:00Z",
+ },
+ ];
+ mockListArtifacts.mockResolvedValue({
+ data: {
+ items,
+ pagination: { page: 1, per_page: 20, total: 1, total_pages: 1 },
+ },
+ error: undefined,
+ });
+ const { artifactsApi } = await import("../artifacts");
+ const result = await artifactsApi.list("pypi-remote");
+ expect(result.items[0]).toMatchObject({
+ cache_cached_at: "2026-06-01T10:00:00Z",
+ cache_expires_at: "2026-06-02T10:00:00Z",
+ });
+ });
+
+ it("leaves cache fields undefined on adaptArtifact when omitted by the backend", async () => {
+ // Backend omits the keys entirely (not null) for non-Remote repos AND
+ // for Remote repos without cache metadata, thanks to
+ // `#[serde(skip_serializing_if = "Option::is_none")]`. The web side
+ // must surface that as `undefined` so the dialog hides the rows --
+ // this test pins that contract at the API-wrapper boundary.
+ mockListArtifacts.mockResolvedValue({
+ data: {
+ items: [
+ {
+ id: "a1",
+ repository_key: "local-repo",
+ path: "lib.jar",
+ name: "lib.jar",
+ size_bytes: 100,
+ checksum_sha256: "x",
+ content_type: "application/octet-stream",
+ download_count: 0,
+ created_at: "2026-06-01T10:00:00Z",
+ },
+ ],
+ pagination: { page: 1, per_page: 20, total: 1, total_pages: 1 },
+ },
+ error: undefined,
+ });
+ const { artifactsApi } = await import("../artifacts");
+ const result = await artifactsApi.list("local-repo");
+ expect(result.items[0].cache_cached_at).toBeUndefined();
+ expect(result.items[0].cache_expires_at).toBeUndefined();
+ });
+
+ it("normalises explicit-null cache fields to undefined on adaptArtifact", async () => {
+ // Defensive: even if a future backend serialises the absent state as
+ // `null` instead of dropping the keys, the wrapper should still
+ // surface `undefined` so the dialog rows don't trip the truthy check.
+ mockListArtifacts.mockResolvedValue({
+ data: {
+ items: [
+ {
+ id: "a1",
+ repository_key: "pypi-remote",
+ path: "x.whl",
+ name: "x.whl",
+ size_bytes: 1,
+ checksum_sha256: "x",
+ content_type: "application/octet-stream",
+ download_count: 0,
+ created_at: "2026-06-01T10:00:00Z",
+ cache_cached_at: null,
+ cache_expires_at: null,
+ },
+ ],
+ pagination: { page: 1, per_page: 20, total: 1, total_pages: 1 },
+ },
+ error: undefined,
+ });
+ const { artifactsApi } = await import("../artifacts");
+ const result = await artifactsApi.list("pypi-remote");
+ expect(result.items[0].cache_cached_at).toBeUndefined();
+ expect(result.items[0].cache_expires_at).toBeUndefined();
+ });
+
// -------------------------------------------------------------------------
// listGrouped (issues #254, #330)
// -------------------------------------------------------------------------
diff --git a/src/lib/api/artifacts.ts b/src/lib/api/artifacts.ts
index e69d882c..53354a17 100644
--- a/src/lib/api/artifacts.ts
+++ b/src/lib/api/artifacts.ts
@@ -37,6 +37,17 @@ export interface ListArtifactsParams {
// doesn't model yet — leave those undefined and let callers fetch detail
// endpoints if they need quarantine state.
function adaptArtifact(sdk: ArtifactResponse): Artifact {
+ // The backend ships new optional fields (cache_cached_at, cache_expires_at)
+ // via artifact-keeper#1541, but the generated SDK type doesn't carry them
+ // until the SDK regenerates from the upgraded OpenAPI spec. Read them off
+ // the runtime object via a narrowed cast so the fields plumb through as
+ // soon as the backend rolls out, without blocking on SDK regen. Once the
+ // SDK is regenerated with the new fields, this cast can collapse to a
+ // direct property access.
+ const sdkAny = sdk as ArtifactResponse & {
+ cache_cached_at?: string | null;
+ cache_expires_at?: string | null;
+ };
return {
id: sdk.id,
repository_key: sdk.repository_key,
@@ -49,6 +60,8 @@ function adaptArtifact(sdk: ArtifactResponse): Artifact {
download_count: sdk.download_count,
created_at: sdk.created_at,
metadata: sdk.metadata ?? undefined,
+ cache_cached_at: sdkAny.cache_cached_at ?? undefined,
+ cache_expires_at: sdkAny.cache_expires_at ?? undefined,
};
}
diff --git a/src/lib/cache-time.ts b/src/lib/cache-time.ts
new file mode 100644
index 00000000..139ee566
--- /dev/null
+++ b/src/lib/cache-time.ts
@@ -0,0 +1,63 @@
+/**
+ * Helpers for rendering proxy-cache freshness fields on the artifact
+ * details dialog (#449). The two functions here both consume an ISO-8601
+ * timestamp and an optional reference `now` Date and return a short
+ * human-readable string.
+ *
+ * Exposing `now` as a parameter is what makes the helpers testable
+ * deterministically — production callers omit it (defaulting to
+ * `new Date()`); tests pass a fixed Date.
+ */
+
+/**
+ * Format an ISO-8601 timestamp as relative-to-now ("in 4 hours",
+ * "12 minutes ago", "expired 3 days ago"). Picks the largest unit that
+ * gives a magnitude >= 1 so the output stays compact (we surface
+ * "2 hours ago" not "120 minutes ago").
+ *
+ * Uses `Intl.RelativeTimeFormat` (universally available in modern
+ * browsers and Node 14+) so the output is locale-aware without pulling
+ * in a date-fns dependency.
+ *
+ * Returns the original string unchanged when it cannot be parsed as a
+ * date — better to show a malformed timestamp than to swallow it.
+ */
+export function formatRelativeTimestamp(iso: string, now: Date = new Date()): string {
+ const t = new Date(iso).getTime();
+ if (Number.isNaN(t)) return iso;
+ const deltaMs = t - now.getTime();
+ const deltaSec = Math.round(deltaMs / 1000);
+ const abs = Math.abs(deltaSec);
+
+ const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
+ ["year", 365 * 24 * 60 * 60],
+ ["month", 30 * 24 * 60 * 60],
+ ["day", 24 * 60 * 60],
+ ["hour", 60 * 60],
+ ["minute", 60],
+ ];
+ const fmt = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+ for (const [unit, secs] of units) {
+ if (abs >= secs) {
+ return fmt.format(Math.round(deltaSec / secs), unit);
+ }
+ }
+ // Anything smaller than a minute (including an exact "now") falls through
+ // to seconds — the guaranteed terminal case, so no dead post-loop return.
+ return fmt.format(deltaSec, "second");
+}
+
+/**
+ * Like `formatRelativeTimestamp` but biased for the "expires" framing —
+ * past timestamps render as "expired N units ago, will re-fetch on next
+ * download" so operators reading the row know what'll happen without
+ * checking docs.
+ */
+export function formatCacheExpiry(iso: string, now: Date = new Date()): string {
+ const t = new Date(iso).getTime();
+ if (Number.isNaN(t)) return iso;
+ if (t <= now.getTime()) {
+ return `expired ${formatRelativeTimestamp(iso, now)}, will re-fetch on next download`;
+ }
+ return formatRelativeTimestamp(iso, now);
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index a44d23c5..f8223ab5 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -177,6 +177,21 @@ export interface Artifact {
quarantine_reason?: string | null;
created_at: string;
metadata?: Record;
+ /**
+ * When the proxy cache entry for this artifact was last written. Only
+ * populated for Remote (proxy) repositories whose backend has been
+ * upgraded with artifact-keeper#1541 (the new optional fields on
+ * `ArtifactResponse`). Renders the "Cached" row in the artifact details
+ * dialog when present.
+ */
+ cache_cached_at?: string | null;
+ /**
+ * When the proxy cache entry for this artifact will expire and be
+ * re-validated against upstream. Same gating as `cache_cached_at`.
+ * Renders the "Cache expires" row in the artifact details dialog when
+ * present.
+ */
+ cache_expires_at?: string | null;
}
export interface PaginatedResponse {