Skip to content
Closed
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
36 changes: 35 additions & 1 deletion src/app/(app)/repositories/_components/repo-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 && (
<DetailRow
label="Cached"
value={formatRelativeTimestamp(
selectedArtifact.cache_cached_at
)}
title={new Date(
selectedArtifact.cache_cached_at
).toLocaleString()}
/>
)}
{repository.repo_type === "remote" &&
selectedArtifact.cache_expires_at && (
<DetailRow
label="Cache expires"
value={formatCacheExpiry(
selectedArtifact.cache_expires_at
)}
title={new Date(
selectedArtifact.cache_expires_at
).toLocaleString()}
/>
)}
<DetailRow
label="SHA-256"
value={selectedArtifact.checksum_sha256}
Expand Down Expand Up @@ -977,19 +1002,28 @@ function DetailRow({
value,
copy,
mono,
title,
}: {
label: string;
value: string;
copy?: boolean;
mono?: boolean;
/**
* Override the hover-tooltip text. Defaults to `value` when omitted.
* Useful for rows where the visible text is a derived/abbreviated form
* (e.g. "in 4 hours") and the full ISO-8601 timestamp belongs in the
* tooltip rather than the visible cell — see the cache_cached_at /
* cache_expires_at rows added in #449.
*/
title?: string;
}) {
return (
<div className="grid grid-cols-[100px_1fr] gap-2 items-start">
<span className="text-muted-foreground text-xs font-medium pt-0.5">{label}</span>
<div className="flex items-center gap-1 min-w-0">
<span
className={`break-all ${mono ? "font-mono text-xs" : ""}`}
title={value}
title={title ?? value}
>
{value}
</span>
Expand Down
75 changes: 75 additions & 0 deletions src/lib/__tests__/cache-time.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
105 changes: 105 additions & 0 deletions src/lib/api/__tests__/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// -------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions src/lib/api/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
}

Expand Down
63 changes: 63 additions & 0 deletions src/lib/cache-time.ts
Original file line number Diff line number Diff line change
@@ -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);
}
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,21 @@ export interface Artifact {
quarantine_reason?: string | null;
created_at: string;
metadata?: Record<string, unknown>;
/**
* 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<T> {
Expand Down
Loading