fix: show flash messages for cache purge and file delete failures

The CI history query schema only declared `page`, so Elysia stripped the
`success` param from the purge-cache redirect. CiHistory also had no
markup for flash messages. The message was dropped twice.

- ci route: accept `success`/`error` in the query schema, pass to the view
- CiHistory: render .form-success / .form-error like BranchList does
- purgeRepoCaches: stop swallowing Docker errors, return the deleted count
  so the message states what happened instead of always claiming success
- FileBlob: render the `error` param, so a failed file delete is visible
AuthorKonata <konata@posteo.jp>
Date
Commit825d230468c8886c9c7d328d7728d7cb267cbeb3
Parent064e468
6 files changed, 72 insertions(+), 25 deletions(-)
▾Msrc/routes/ci.tsx
@@ -203,10 +203,18 @@ export const ciRoutes = new Elysia()
}}
manualTriggerDisabledReason={manualTriggerDisabledReason}
ciVariables={ciVariables}
success={query.success}
error={query.error}
/>,
);
},
{ query: t.Object({ page: t.Optional(t.Number()) }) },
{
query: t.Object({
page: t.Optional(t.Number()),
success: t.Optional(t.String()),
error: t.Optional(t.String()),
}),
},
)
// Run detail
@@ -391,12 +399,22 @@ export const ciRoutes = new Elysia()
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
await purgeRepoCaches(repo.name);
let message: string;
try {
const removed = await purgeRepoCaches(repo.name);
message = `success=${encodeURIComponent(
removed === 0
? "No cache volumes to purge."
: `Purged ${removed} cache volume${removed === 1 ? "" : "s"}.`,
)}`;
} catch {
message = `error=${encodeURIComponent("Failed to purge caches. Is Docker reachable?")}`;
}
return new Response(null, {
status: 302,
headers: {
Location: `/${repo.name}/ci?success=Cache+purged.`,
Location: `/${repo.name}/ci?${message}`,
},
});
})
▾Msrc/routes/repos.tsx
@@ -590,7 +590,7 @@ export const repoRoutes = new Elysia()
);
})
.get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
.get("/:repo/blob/:ref/*", async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
@@ -602,6 +602,8 @@ export const repoRoutes = new Elysia()
// a public repo.
const size = await git.getFileSize(repo.name, params.ref, filePath);
const filename = path.basename(filePath);
const blobError =
typeof query.error === "string" ? query.error : undefined;
if (size !== null && size > config.MAX_RENDER_BYTES) {
const [branches, tags] = await Promise.all([
git.branches(repo.name),
@@ -617,6 +619,7 @@ export const repoRoutes = new Elysia()
branches={branches}
tags={tags}
markdownHtml={undefined}
error={blobError}
/>,
);
}
@@ -662,6 +665,7 @@ export const repoRoutes = new Elysia()
branches={branches}
tags={tags}
markdownHtml={markdownHtml}
error={blobError}
/>,
);
})
▾Msrc/services/ci.ts
@@ -1732,28 +1732,28 @@ async function pruneHistory(repoId: number): Promise<void> {
await db.deleteFrom("ci_runs").where("id", "in", toDelete).execute();
}
export async function purgeRepoCaches(repoName: string): Promise<void> {
try {
const filters = encodeURIComponent(
JSON.stringify({ label: [`com.hearthforge.repo=${repoName}`] }),
);
const resp = await dockerFetch(`/volumes?filters=${filters}`);
if (!resp.ok) {
await resp.body?.cancel();
return;
}
const data = (await resp.json()) as {
Volumes?: Array<{ Name: string }>;
};
for (const vol of data.Volumes ?? []) {
const delResp = await dockerFetch(`/volumes/${vol.Name}`, {
method: "DELETE",
});
await delResp.body?.cancel();
}
} catch {
// Best-effort
/** Deletes this repo's cache volumes. Returns how many were removed. Throws when Docker is unreachable. */
export async function purgeRepoCaches(repoName: string): Promise<number> {
const filters = encodeURIComponent(
JSON.stringify({ label: [`com.hearthforge.repo=${repoName}`] }),
);
const resp = await dockerFetch(`/volumes?filters=${filters}`);
if (!resp.ok) {
await resp.body?.cancel();
throw new Error(`Docker returned ${resp.status} listing volumes`);
}
const data = (await resp.json()) as {
Volumes?: Array<{ Name: string }>;
};
let removed = 0;
for (const vol of data.Volumes ?? []) {
const delResp = await dockerFetch(`/volumes/${vol.Name}`, {
method: "DELETE",
});
await delResp.body?.cancel();
if (delResp.ok) removed++;
}
return removed;
}
export async function cancelStaleRuns(): Promise<void> {
▾Msrc/views/ci/CiHistory.tsx
@@ -32,6 +32,8 @@ interface CiHistoryProps {
manualTriggerDisabledReason: string | null;
/** Declared in [variables]. Null when the config could not be read. */
ciVariables: Record<string, CiVariableDef> | null;
success?: string;
error?: string;
}
function queueTitle(position: number | null): string {
@@ -239,6 +241,8 @@ export function CiHistory({
pagination,
manualTriggerDisabledReason,
ciVariables,
success,
error,
}: CiHistoryProps) {
const varEntries = Object.entries(ciVariables ?? {});
const isRunning = runs.some(
@@ -253,6 +257,16 @@ export function CiHistory({
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
{!!success && (
<p class="form-success" safe>
{success}
</p>
)}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<div class="list-header">
<h2 class="list-heading">Pipelines</h2>
{user?.isAdmin && (
▾Msrc/views/repos/FileBlob.tsx
@@ -15,6 +15,7 @@ interface FileBlobProps {
branches: string[];
tags?: string[];
markdownHtml?: string;
error?: string;
}
export function FileBlob({
@@ -26,6 +27,7 @@ export function FileBlob({
branches,
tags,
markdownHtml,
error,
}: FileBlobProps) {
const parts = filePath.split("/");
const filename = parts[parts.length - 1] ?? filePath;
@@ -34,6 +36,11 @@ export function FileBlob({
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${blobRef}`} safe>
{repo.name}
▾Mtests/e2e.ci.test.ts
@@ -1125,6 +1125,10 @@ describe("purge cache", () => {
await page.waitForURL(/\/ci-repo\/ci/);
// History page loads without error
expect(await page.locator("h2").textContent()).toContain("Pipelines");
// And reports the outcome to the user
expect(
await page.locator(".form-success, .form-error").textContent(),
).toMatch(/purge/i);
} finally {
await page.close();
}