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
86 changes: 86 additions & 0 deletions src/app/(app)/repositories/_components/repo-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Layers,
Package as PackageIcon,
Settings,
RotateCcw,
} from "lucide-react";

import { repositoriesApi } from "@/lib/api/repositories";
Expand All @@ -41,6 +42,7 @@ import { MavenComponentList } from "./maven-component-list";
import { DockerTagList } from "./docker-tag-list";
import { QuarantineBadge } from "@/components/common/quarantine-badge";
import { QuarantineBanner } from "@/components/common/quarantine-banner";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { RepoSettingsTab } from "./repo-settings-tab";
import { formatBytes, REPO_TYPE_COLORS } from "@/lib/utils";
import { useAuth } from "@/providers/auth-provider";
Expand Down Expand Up @@ -112,6 +114,11 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon
// artifact detail dialog
const [detailOpen, setDetailOpen] = useState(false);
const [selectedArtifact, setSelectedArtifact] = useState<Artifact | null>(null);
// Confirm gate for the destructive "Invalidate cache" action (#446 review).
const [invalidateConfirmOpen, setInvalidateConfirmOpen] = useState(false);
// Polite live-region text announcing the cache-invalidation outcome to
// assistive tech (#446 review): the toast alone is not reliably announced.
const [cacheActionStatus, setCacheActionStatus] = useState("");

// security form local state
const [secForm, setSecForm] = useState<UpsertScanConfigRequest | null>(null);
Expand Down Expand Up @@ -210,6 +217,42 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon
onError: mutationErrorToast("Failed to trigger scan"),
});

// Invalidate a single cached entry on a Remote (proxy) repository
// (artifact-keeper#1539 / artifact-keeper-web#446). Backend rejects this on
// non-Remote repos with 400, but we also gate the button below on
// `repository.repo_type === "remote"` so the operation is never offered
// for repos without a cache.
const invalidateCacheMutation = useMutation({
mutationFn: (path: string) => artifactsApi.invalidateCache(repoKey, path),
onSuccess: async (_result, path) => {
// Drop the artifacts list and repo summary from the cache so the next
// fetch goes back to upstream (the underlying download endpoint will
// re-populate the proxy cache on the next access).
queryClient.invalidateQueries({ queryKey: ["artifacts", repoKey] });
queryClient.invalidateQueries({ queryKey: ["repository", repoKey] });
// The dialog holds a local copy of the artifact; after eviction its
// cache metadata (cache_cached_at / cache_expires_at, surfaced by #449)
// is stale. Refresh the open row from the server so those values
// reflect the eviction instead of the pre-eviction snapshot (#446
// review). Best-effort: the list/summary are already invalidated, so a
// failed refresh just leaves the last-known values in place.
try {
const fresh = await artifactsApi.get(repoKey, path);
setSelectedArtifact((prev) => (prev && prev.path === path ? fresh : prev));
} catch {
// non-fatal
}
setCacheActionStatus(
"Cache entry invalidated; the next download will re-fetch from upstream.",
);
toast.success("Cache entry invalidated; next download will re-fetch from upstream.");
},
onError: (err: unknown) => {
setCacheActionStatus("Cache invalidation failed. Please try again.");
mutationErrorToast("Failed to invalidate cache")(err);
},
});

const scanRepoMutation = useMutation({
mutationFn: () =>
securityApi.triggerScan({ repository_id: repository?.id }),
Expand Down Expand Up @@ -271,6 +314,7 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon

const showDetail = useCallback((artifact: Artifact) => {
setSelectedArtifact(artifact);
setCacheActionStatus("");
setDetailOpen(true);
}, []);

Expand Down Expand Up @@ -957,15 +1001,57 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon
<Trash2 className="size-4" />
Delete
</Button>
{repository.repo_type === "remote" && (
<Button
variant="outline"
onClick={() => setInvalidateConfirmOpen(true)}
disabled={invalidateCacheMutation.isPending}
title="Evict this artifact from the proxy cache; next download re-fetches from upstream"
>
<RotateCcw className="size-4" />
{invalidateCacheMutation.isPending
? "Invalidating..."
: "Invalidate cache"}
</Button>
)}
<Button onClick={() => selectedArtifact && handleDownload(selectedArtifact)}>
<Download className="size-4" />
Download
</Button>
</>
)}
</DialogFooter>
{/*
Polite live region for async cache-invalidation feedback (#446
review). Toasts are not reliably announced by assistive tech, and
the button's "Invalidating..." label swap is otherwise silent.
`sr-only` keeps it visually invisible while announcing to AT.
*/}
<div role="status" aria-live="polite" className="sr-only">
{invalidateCacheMutation.isPending
? "Invalidating cache entry…"
: cacheActionStatus}
</div>
</DialogContent>
</Dialog>

{/* Confirm gate for the destructive "Invalidate cache" action (#446). */}
<ConfirmDialog
open={invalidateConfirmOpen}
onOpenChange={setInvalidateConfirmOpen}
title="Invalidate cached artifact?"
description="This evicts the cached copy from the proxy. The next download re-fetches it from the upstream remote. This cannot be undone."
confirmText="Invalidate cache"
danger
loading={invalidateCacheMutation.isPending}
onConfirm={() => {
if (selectedArtifact) {
invalidateCacheMutation.mutate(selectedArtifact.path, {
onSettled: () => setInvalidateConfirmOpen(false),
});
}
}}
/>
</div>
);
}
Expand Down
85 changes: 85 additions & 0 deletions src/lib/api/__tests__/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,91 @@ describe("artifactsApi", () => {
await expect(artifactsApi.delete("repo-key", "lib.jar")).rejects.toBe("fail");
});

// -------------------------------------------------------------------------
// invalidateCache (artifact-keeper#1539 / artifact-keeper-web#446)
// -------------------------------------------------------------------------

describe("invalidateCache", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("issues POST to /api/v1/repositories/:key/cache/invalidate?path=...", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(
JSON.stringify({
repository_key: "pypi-remote",
path: "simple/requests/",
invalidated: true,
})
),
});
global.fetch = fetchMock;

const { artifactsApi } = await import("../artifacts");
const result = await artifactsApi.invalidateCache(
"pypi-remote",
"simple/requests/"
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const url = String(fetchMock.mock.calls[0][0]);
expect(url).toContain("/api/v1/repositories/pypi-remote/cache/invalidate");
expect(url).toContain("path=simple%2Frequests%2F");
const init = fetchMock.mock.calls[0][1];
expect(init).toEqual(
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
expect(result).toEqual({
repository_key: "pypi-remote",
path: "simple/requests/",
invalidated: true,
});
});

it("URL-encodes both the repository key and the path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(
JSON.stringify({ repository_key: "x", path: "y", invalidated: true })
),
});
global.fetch = fetchMock;

const { artifactsApi } = await import("../artifacts");
await artifactsApi.invalidateCache("repo with spaces", "a b/c+d.tgz");
const url = String(fetchMock.mock.calls[0][0]);
expect(url).toContain("/api/v1/repositories/repo%20with%20spaces/cache/invalidate");
// `path` is in the query string and must be percent-encoded so '/' and
// '+' survive the round-trip back into the path the backend evicts.
expect(url).toContain("path=a%20b%2Fc%2Bd.tgz");
});

it("throws on non-ok response (e.g. 400 for non-remote repo, 503 for missing storage)", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: vi.fn().mockResolvedValue(
JSON.stringify({
error: "VALIDATION_ERROR",
message:
"cache invalidation is only supported on remote (proxy) repositories",
})
),
});
const { artifactsApi } = await import("../artifacts");
await expect(
artifactsApi.invalidateCache("local-only", "foo.jar")
).rejects.toThrow(/API error 400/);
});
});

it("getDownloadUrl returns correct URL", async () => {
const { artifactsApi } = await import("../artifacts");
expect(artifactsApi.getDownloadUrl("repo-key", "com/lib.jar")).toBe(
Expand Down
24 changes: 24 additions & 0 deletions src/lib/api/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,30 @@ export const artifactsApi = {
if (error) throw error;
},

/**
* Invalidate a single cached artifact entry on a Remote (proxy) repository
* (artifact-keeper#1539). Routes through `apiFetch` because the generated
* SDK has not been regenerated against the new endpoint yet; once it is,
* this can collapse to the typed SDK call.
*
* Idempotent on the backend: invalidating a path that was never cached
* still resolves successfully, matching `ProxyService::invalidate_cache`.
* Caller is responsible for invalidating any React-Query caches that
* depend on the artifact (the artifacts list, the repository row).
*/
invalidateCache: async (
repoKey: string,
artifactPath: string,
): Promise<{ repository_key: string; path: string; invalidated: boolean }> => {
const url =
`/api/v1/repositories/${encodeURIComponent(repoKey)}/cache/invalidate` +
`?path=${encodeURIComponent(artifactPath)}`;
return apiFetch<{ repository_key: string; path: string; invalidated: boolean }>(
url,
{ method: 'POST' },
);
},

getDownloadUrl: (repoKey: string, artifactPath: string): string => {
return `/api/v1/repositories/${repoKey}/download/${artifactPath}`;
},
Expand Down
Loading