diff --git a/src/app/(app)/repositories/_components/repo-detail-content.tsx b/src/app/(app)/repositories/_components/repo-detail-content.tsx index c5309209..e7b197fb 100644 --- a/src/app/(app)/repositories/_components/repo-detail-content.tsx +++ b/src/app/(app)/repositories/_components/repo-detail-content.tsx @@ -24,6 +24,7 @@ import { artifactsApi } from "@/lib/api/artifacts"; import securityApi from "@/lib/api/security"; import { mutationErrorToast } from "@/lib/error-utils"; import { isActivelyQuarantined } from "@/lib/quarantine"; +import { formatRelativeTimestamp, formatCacheExpiry } from "@/lib/cache-time"; import type { Artifact } from "@/types"; import type { UpsertScanConfigRequest } from "@/types/security"; import { SbomTabContent } from "./sbom-tab-content"; @@ -889,6 +890,30 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon label="Created" value={new Date(selectedArtifact.created_at).toLocaleString()} /> + {repository.repo_type === "remote" && + selectedArtifact.cache_cached_at && ( + + )} + {repository.repo_type === "remote" && + selectedArtifact.cache_expires_at && ( + + )} @@ -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 {