diff --git a/app/package-lock.json b/app/package-lock.json index 85a0dd3..e87abfb 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,7 +12,6 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-opener": "^2.5.3", - "@tauri-apps/plugin-store": "^2", "@xterm/addon-fit": "^0.10", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.18", @@ -2010,15 +2009,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-store": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", - "integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/app/package.json b/app/package.json index 8a1eae1..f9d2f0d 100644 --- a/app/package.json +++ b/app/package.json @@ -16,7 +16,6 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-opener": "^2.5.3", - "@tauri-apps/plugin-store": "^2", "@xterm/addon-fit": "^0.10", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.18", diff --git a/app/src/components/projects/home/BrowserTab.test.tsx b/app/src/components/projects/home/BrowserTab.test.tsx index 8e5303f..df7624b 100644 --- a/app/src/components/projects/home/BrowserTab.test.tsx +++ b/app/src/components/projects/home/BrowserTab.test.tsx @@ -571,4 +571,57 @@ describe("BrowserTab", () => { // The view is still in the tab, where it was. expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument(); }); + + it("pins the pane's sandbox: same-origin kept, top-navigation never granted", async () => { + await renderLive(); + const frame = await screen.findByTitle("Playwright browser view for api-server"); + const tokens = new Set( + (frame.getAttribute("sandbox") ?? "").split(/\s+/).filter(Boolean), + ); + + // What is framed here is served by a process *inside* the container, which + // is the untrusted side of this app. A top-navigation grant would let that + // page set `top.location` and steer the whole Triple-C app window away from + // itself — a sandbox escape from the app's point of view — and + // `allow-popups-to-escape-sandbox` is the same hole one step removed: it + // hands a popup an entirely unsandboxed context. Checked token by token so + // the failure names the one that was added. + for (const forbidden of [ + "allow-top-navigation", + "allow-top-navigation-by-user-activation", + "allow-top-navigation-to-custom-protocols", + "allow-popups-to-escape-sandbox", + ]) { + expect( + tokens.has(forbidden), + `FORBIDDEN iframe sandbox token "${forbidden}" on the browser view pane. ` + + "A page served from inside the container could then navigate the whole " + + "Triple-C app window away from itself (or run a popup unsandboxed) — a " + + "sandbox escape. Remove it from the iframe in BrowserTab.tsx.", + ).toBe(false); + } + + // `allow-same-origin` must stay. The browser_view proxy's token gate + // recognises the pane's own sub-resource requests by their `Origin`/ + // `Referer` header; dropping this token gives the frame an opaque origin, + // which sends `null`, so the proxy refuses those requests and the pane + // renders blank. + expect( + tokens.has("allow-same-origin"), + 'REQUIRED iframe sandbox token "allow-same-origin" is missing from the ' + + "browser view pane. Without it the frame has an opaque origin and sends " + + "`Origin: null`, which the browser_view proxy's token gate refuses — the " + + "pane goes blank.", + ).toBe(true); + + // And the exact set, so any *other* new grant is a deliberate edit here too. + expect([...tokens].sort()).toEqual([ + "allow-downloads", + "allow-forms", + "allow-modals", + "allow-popups", + "allow-same-origin", + "allow-scripts", + ]); + }); }); diff --git a/app/src/components/projects/home/FilesTab.test.tsx b/app/src/components/projects/home/FilesTab.test.tsx index 54691d9..38ff127 100644 --- a/app/src/components/projects/home/FilesTab.test.tsx +++ b/app/src/components/projects/home/FilesTab.test.tsx @@ -824,3 +824,76 @@ describe("FilesTab overwrite prompt", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); }); + +/** + * Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free, + * and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because + * the destructive answer has to be chosen, and because a dialog that is closed + * rather than answered must not leave the batch waiting forever or throw away + * the files behind it. + */ +describe("FilesTab overwrite prompt dismissal", () => { + /** + * Drop two files where the first name is taken, and stop at the dialog. The + * unsettled batch comes back wrapped — returning it bare from an `async` + * helper would adopt it, and awaiting the helper would then wait for an + * upload that cannot proceed until the helper has returned. + */ + async function dropIntoConflict(): Promise<{ batch: Promise | undefined }> { + uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists"); + await renderTab(); + const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]); + await screen.findByRole("dialog"); + return { batch }; + } + + /** What every dismissal has to leave behind: one skip, one upload, no clobber. */ + function expectSkippedAndCarriedOn() { + expect(screen.queryByRole("dialog")).toBeNull(); + expect(uploadFileToContainer).toHaveBeenCalledTimes(2); + expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace"); + expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false); + expect(screen.getByRole("status").textContent).toContain("skipped 1"); + } + + it("counts Escape as a Skip", async () => { + const { batch } = await dropIntoConflict(); + await act(async () => { + fireEvent.keyDown(document, { key: "Escape" }); + await batch; + }); + expectSkippedAndCarriedOn(); + }); + + it("counts the ✕ as a Skip", async () => { + const { batch } = await dropIntoConflict(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Close dialog" })); + await batch; + }); + expectSkippedAndCarriedOn(); + }); + + it("counts a click on the backdrop as a Skip", async () => { + const { batch } = await dropIntoConflict(); + // The overlay is the dialog panel's parent — `Modal` only closes when the + // click landed on the overlay itself, not on anything inside the panel. + const overlay = screen.getByRole("dialog").parentElement!; + await act(async () => { + fireEvent.click(overlay); + await batch; + }); + expectSkippedAndCarriedOn(); + }); + + it("does not dismiss on a click inside the dialog", async () => { + const { batch } = await dropIntoConflict(); + fireEvent.click(screen.getByRole("dialog")); + expect(screen.queryByRole("dialog")).not.toBeNull(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await batch; + }); + expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true); + }); +}); diff --git a/app/src/components/settings/DiskProjectTable.tsx b/app/src/components/settings/DiskProjectTable.tsx index a787fbf..6886d7b 100644 --- a/app/src/components/settings/DiskProjectTable.tsx +++ b/app/src/components/settings/DiskProjectTable.tsx @@ -26,6 +26,29 @@ function cell(bytes: number, present: boolean) { return present ? formatBytes(bytes) : "—"; } +const SNAPSHOT_HELP = + "This project's share of its snapshot image — the bytes no other image carries. The base image is shared by every project, so charging it to each row would show the same 4.7 GB eight times over. It is the figure the Total is built from."; + +/** Why a snapshot figure is the whole image rather than a share of one. */ +const SPLIT_UNKNOWN_HELP = + "Nothing measurably shares layers with this snapshot, and the base image it descends from is no longer on the daemon, so there is no split to show and none is guessed. This is the whole image, which is what it actually costs — a compacted snapshot is exactly this shape."; + +/** + * How `snapshot_attributed_bytes` was arrived at, in the row's own terms. + * + * Rust computes the number in one function so the column and the Total cannot + * be derived from two different rules again — but the branches do not mean the + * same thing to a reader, so the sub-line has to say which one this row is. + * `snapshot_above_base_bytes` is `null` in exactly the branch where the figure + * *is* the whole image, which is what makes it the test. + */ +function attributionNote(row: ProjectDiskRow): { note: string; help: string | null } { + if (row.snapshot_above_base_bytes !== null) { + return { note: `${formatBytes(row.snapshot_bytes)} with base`, help: null }; + } + return { note: "whole image — base unknown", help: SPLIT_UNKNOWN_HELP }; +} + /** * The per-project table — the mental model users actually have of this app. * @@ -64,8 +87,10 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props Project - + Snapshot + + — {SNAPSHOT_HELP} Layers @@ -121,22 +146,33 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props - {/* `null` means the split could not be measured. Rendering it - as 0 B would be the one guessed number in this table. */} - {cell( - row.snapshot_above_base_bytes ?? -1, - row.snapshot_exists && row.snapshot_above_base_bytes !== null, - )} - {row.snapshot_exists && ( - - {/* The base is shared by every project, so charging it to - each row would show the same 4.7 GB eight times. The - headline figure is what is unique to this project; - the total is here for anyone reconciling against - `docker images`. */} - {formatBytes(row.snapshot_bytes)} with base - - )} + {/* `snapshot_attributed_bytes`, and nothing else. This column + used to render `snapshot_above_base_bytes` and fall back + to `—` while the Total was computed from + `snapshot_bytes - snapshot_shared_bytes` regardless — so a + row could show `—` here and still carry a whole 4.7 GB + base image in its Total, once per project. One field, one + rule, computed once in Rust: the parts add up. */} + {row.snapshot_exists ? formatBytes(row.snapshot_attributed_bytes) : "—"} + {row.snapshot_exists && (() => { + const { note, help } = attributionNote(row); + return help === null ? ( + + {note} + + ) : ( + // Same treatment as the Layers column: `Tooltip` portals + // a plain div with no `role` and no `aria-describedby`, + // so the explanation is also emitted as screen-reader + // text rather than living in the tooltip alone. + + + {note} + + — {help} + + ); + })()} {!row.snapshot_exists ? ( diff --git a/app/src/components/settings/DiskSettings.test.tsx b/app/src/components/settings/DiskSettings.test.tsx index f2777b3..8a363b2 100644 --- a/app/src/components/settings/DiskSettings.test.tsx +++ b/app/src/components/settings/DiskSettings.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react"; import DiskSettings from "./DiskSettings"; import type { + DestructiveItem, DiskUsageReport, ProjectDiskRow, ReclaimItem, @@ -45,6 +46,9 @@ const row = (over: Partial = {}): ProjectDiskRow => ({ home_volume_present: true, config_volume_bytes: 427_000_000, config_volume_present: true, + // The one figure the Snapshot column shows and the Total is built from. + // 8.44 + 0.868 + 4.86 + 0.427 == 14.596, and the table is expected to add up. + snapshot_attributed_bytes: 8_440_966_715, total_bytes: 14_595_966_715, migrating: false, ...over, @@ -115,6 +119,26 @@ const result = (over: Partial = {}): ReclaimResult => ({ ...over, }); +/** An orphaned volume, as `list_reclaimable` now describes it: a + * `DestructiveItem`, never a `ReclaimItem`. `project_name` carries the + * *volume* name, because there is no project to name — that is the definition + * of the variant, and it is what `destroy` compares the typed string against. */ +const orphan = (over: Partial = {}): DestructiveItem => ({ + target: { + kind: "orphan_volume", + name: "triple-c-claude-config-gone", + project_id: "gone", + }, + project_id: "gone", + project_name: "triple-c-claude-config-gone", + label: "triple-c-claude-config-gone (config volume)", + loses: + "Named for project id gone, which is not in Triple-C's project list, and no container is attached to it. Docker created it on 2026-03-14T09:00:00Z. This is a `.claude` volume — it held that project's Claude credential, plugins and session transcripts. Not recoverable. Type the volume name to confirm.", + bytes: 900_000, + blocked: null, + ...over, +}); + const plan = (over: Partial = {}): ReclaimPlan => ({ items: [item()], destructive: [], @@ -196,14 +220,43 @@ describe("DiskSettings", () => { expect(within(projectRow).queryByText("17")).not.toBeInTheDocument(); }); - it("renders an unmeasurable snapshot split as a dash, never as zero", async () => { + it("builds the Snapshot column and the Total from the same attributed figure", async () => { + // The bug this pins: the column rendered `snapshot_above_base_bytes` and + // fell back to `—`, while the total was `snapshot_bytes - + // snapshot_shared_bytes` regardless — which in the fallback branch is the + // whole 4.7 GB base image, charged to every row and then added again as a + // base-image row in the globals. One field, computed once in Rust. + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument(); + expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument(); + }); + + it("says a snapshot figure is the whole image rather than passing it off as a share", async () => { + // `snapshot_above_base_bytes` is null in exactly one branch: nothing + // measurably shares layers with the snapshot *and* its base is gone. The + // attributed figure is then the whole image — an honest cost, not a guess + // and not zero — but it does not mean what the other rows' figures mean, + // so the sub-line has to say which one this is. getDockerDiskUsage.mockResolvedValue( - report({ projects: [row({ snapshot_above_base_bytes: null })] }), + report({ + projects: [ + row({ + snapshot_shared_bytes: 0, + snapshot_above_base_bytes: null, + snapshot_attributed_bytes: 12_273_392_374, + total_bytes: 18_428_392_374, + }), + ], + }), ); await renderAndScan(); const projectRow = await screen.findByTestId("disk-row-p-whp"); expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument(); - expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0); + expect(within(projectRow).getByText("12.3 GB")).toBeInTheDocument(); + expect(projectRow.textContent).toMatch(/whole image — base unknown/); + // And it must not still claim the "N with base" split it cannot measure. + expect(projectRow.textContent).not.toMatch(/with base/); }); it("marks a heavily stacked snapshot with a word, not just a colour", async () => { @@ -424,6 +477,79 @@ describe("DiskSettings", () => { ); }); + it("never offers an orphaned volume as a tick in the safe bucket", async () => { + // It used to be a `ReclaimTarget` at `Safety::Safe` — a tick and the group + // Reclaim button, no confirmation — for a volume holding a Claude + // credential and every transcript a project ever had. The Rust variant is + // gone; this pins that the frontend cannot resurrect it. + listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] })); + await renderAndScan(); + const safe = await screen.findByTestId("disk-safe-bucket"); + expect(within(safe).getAllByRole("checkbox")).toHaveLength(1); + expect(safe.textContent).not.toMatch(/triple-c-claude-config-gone/); + // And it is reachable — an item that matches no project row would + // otherwise simply vanish from the UI. + expect(await screen.findByTestId("disk-orphan-bucket")).toBeInTheDocument(); + }); + + it("keeps orphaned volumes out of the per-project table", async () => { + // The table keys off `project_id`, and an orphan's id matches no row by + // definition. Passing them in anyway is how one would leak into the wrong + // project's overflow menu if a row ever shared the id. + listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] })); + await renderAndScan(); + const projectRow = await screen.findByTestId("disk-row-p-whp"); + expect(projectRow.textContent).not.toMatch(/triple-c-claude-config-gone/); + }); + + it("says what a config volume actually holds, not 'volume data'", async () => { + listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] })); + await renderAndScan(); + const bucket = await screen.findByTestId("disk-orphan-bucket"); + expect(bucket.textContent).toMatch(/Claude login credential/i); + expect(bucket.textContent).toMatch(/every plugin and skill installed into it/i); + expect(bucket.textContent).toMatch(/every conversation transcript it ever had/i); + // The derivation caveat travels with the offer, not only with the totals. + expect(bucket.textContent).toMatch(/not.*inferred from a project being stopped/i); + }); + + it("confirms an orphaned volume against its own name, never a project's", async () => { + listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] })); + destroyProjectDiskObject.mockResolvedValue({ results: [], total_freed_bytes: 0 }); + await renderAndScan(); + const bucket = await screen.findByTestId("disk-orphan-bucket"); + await act(async () => { + fireEvent.click(within(bucket).getByRole("button", { name: /Delete/ })); + }); + + const dialog = screen.getByRole("dialog"); + // Asking for "the exact project name" would be asking for a string that + // does not exist. + expect(within(dialog).getByRole("status")).toHaveTextContent( + "Waiting for the exact volume name.", + ); + const input = within(dialog).getByLabelText(/Type/); + const confirm = within(dialog).getByRole("button", { name: "Delete volume" }); + + // The project id parsed out of the name is display only and must not open + // the gate. + fireEvent.change(input, { target: { value: "gone" } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: "triple-c-claude-config-gone" } }); + expect(confirm).toBeEnabled(); + await act(async () => { + fireEvent.click(confirm); + }); + + expect(destroyProjectDiskObject).toHaveBeenCalledWith( + { kind: "orphan_volume", name: "triple-c-claude-config-gone", project_id: "gone" }, + "triple-c-claude-config-gone", + ); + // One volume, one confirmation — `reclaim` never sees it. + expect(reclaim).not.toHaveBeenCalled(); + }); + it("explains a suppressed orphan list instead of showing an empty one", async () => { // With the project store unreadable every project's volumes look // unclaimed. Showing nothing is right; showing nothing *silently* is not. @@ -647,7 +773,7 @@ describe("DiskSettings", () => { result({ target: { kind: "migration_pins" } }), result({ target: { kind: "probe_containers" } }), result({ target: { kind: "build_cache", all: true }, ok: false }), - result({ target: { kind: "orphan_volume", name: "v" }, ok: false }), + result({ target: { kind: "scrub_containers" }, ok: false }), ], total_freed_bytes: 1_200_000_000, }); diff --git a/app/src/components/settings/DiskSettings.tsx b/app/src/components/settings/DiskSettings.tsx index cc21ba4..1451f65 100644 --- a/app/src/components/settings/DiskSettings.tsx +++ b/app/src/components/settings/DiskSettings.tsx @@ -13,6 +13,23 @@ function targetKey(target: ReclaimTarget): string { return JSON.stringify(target); } +/** The same, for a destructive object — never ticked, but still listed. */ +function destructiveKey(item: DestructiveItem): string { + return JSON.stringify(item.target); +} + +/** + * An orphaned volume is confirmed against **its own name**, not a project's. + * + * There is no project to name: the whole definition of the variant is that its + * id matches nothing in the store, and `disk.rs`'s `destroy` takes the orphan + * arm before it ever looks a project up. `DestructiveItem.project_name` carries + * the volume name for exactly these items, which is what the gate compares. + */ +function isOrphanVolume(item: DestructiveItem): boolean { + return item.target.kind === "orphan_volume"; +} + /** * Where the disk went, and how to get it back. * @@ -25,17 +42,31 @@ function targetKey(target: ReclaimTarget): string { * * ## Why the buckets are separated the way they are * - * Safe work (dangling images, ownerless pins, build cache, volumes whose - * project id is not in the project store) gets one list of ticks and one - * button, because none of it can lose anything a user has. Note what the last - * of those is derived from: membership in Triple-C's own project list, never - * "this project has no container" — an idle live project looks exactly like a - * deleted one from the daemon's side, and mistaking the two would delete - * credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a - * re-download and is confirmed one at a time. Destructive work — a live - * project's volumes, its snapshot, a live rollback pin — is not in either list: - * it is reached only from that project's own row, behind a typed confirmation, - * and the backend refuses it in bulk by taking a different type entirely. + * Safe work (dangling images, ownerless pins, build cache) gets one list of + * ticks and one button, because none of it can lose anything a user has. + * Semi-safe work (compaction, cache clearing) is a rewrite or a re-download and + * is confirmed one at a time. Destructive work — a live project's volumes, its + * snapshot, a live rollback pin, **and an orphaned volume** — is not in either + * list: it is reached one object at a time, behind a typed confirmation, and + * the backend refuses it in bulk by taking a different type entirely. + * + * ## Why orphaned volumes are down there and not in the tick list + * + * They used to be a `ReclaimTarget` at `Safety::Safe`: a tick and the group + * Reclaim button, no confirmation. The object behind that tick is a + * `triple-c-claude-config-*` volume holding a Claude OAuth credential, every + * plugin and skill installed into that project, and every conversation + * transcript it ever had — and the *same volume* for a project still in the + * store required typing the project's name. The only difference between the two + * is a lookup against `projects.json`, which this app has been wrong about + * before: a second instance's project is absent from an in-memory list, a + * corrupt store empties it, a restored data directory empties it too. It once + * flagged two live projects as orphaned. + * + * So "no matching project" means one thing only — the id is not in the project + * list. It is never inferred from a project being stopped, having no container + * or having no image; an idle live project looks identical from the daemon's + * side. Each volume is deleted on its own, against its own name typed out. */ export default function DiskSettings() { const { @@ -68,6 +99,14 @@ export default function DiskSettings() { if (!plan) setTicked(new Set()); }, [plan]); + // Split before anything renders. The per-project table keys off + // `project_id`, and an orphan's id matches no row by definition — so without + // this split those items are simply invisible, which is how a variant that + // moved from the tick list to the destructive list can vanish from the UI + // entirely rather than reappear behind a confirmation. + const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? []; + const projectDestructive = plan?.destructive.filter((d) => !isOrphanVolume(d)) ?? []; + const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? []; const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? []; const selected = safeItems.filter( @@ -201,7 +240,7 @@ export default function DiskSettings() { @@ -282,8 +321,9 @@ export default function DiskSettings() { volume’s project id is not in your project list — it is{" "} not inferred from a project being stopped or having no image. A project you have not opened in a while has no container and no snapshot either, and that is normal, so - each of these is ticked individually and shows the date Docker created - it. + nothing here is deleted in a group: each one is listed below on its own, + with the date Docker created it, and removing it takes typing that + volume’s name.

)}

@@ -455,6 +495,72 @@ export default function DiskSettings() { )} + {/* --- Orphaned volumes: destructive, one at a time ---------------- */} + {orphanVolumes.length > 0 && ( +

+

+ Volumes with no matching project +

+

+ A volume here is one whose project id is not in your project list. That + is all it means — it is not inferred from a + project being stopped, having no container or having no image. An idle + live project looks exactly the same from Docker’s side, and that + inference has already flagged two live projects here once. +

+

+ Deleting a{" "} + triple-c-claude-config-* volume + deletes{" "} + + the Claude login credential that project signed in with, every plugin + and skill installed into it, and every conversation transcript it ever + had + + . A triple-c-home-* volume holds its + dotfiles, shell history and installed toolchains. There is no other copy + of either and nothing regenerates, so each one is deleted on its own, + against that volume’s name typed out — never as part of a + group. +

+
    + {orphanVolumes.map((item) => ( +
  • + + + {item.label} + + + {item.loses} + + {item.blocked && ( + + {item.blocked} + + )} + + + + {formatBytes(item.bytes)} + + + +
  • + ))} +
+
+ )} + {/* --- Sweep ------------------------------------------------------ */}