Add a Disk section: see where the bytes went, and get them back
Every recreation runs `docker commit`, which stacks a layer and never rewrites one, and 24 conditions in `container_needs_recreation` trigger a recreation. Prevention landed earlier on this branch; this is the half a user can act on. The per-project table leads with the two numbers that explain the mechanism rather than just the total: how many commit layers a snapshot has stacked above its base, and what the container's writable layer will add at the next commit. Backend (`docker/disk.rs`, commands in `docker_commands.rs`): - `get_docker_disk_usage` — one `df()` joined against the project store, behind an explicit Scan button because it walks the whole daemon. - `list_reclaimable` / `reclaim` — classified buckets with measured bytes, planned off the existing report so re-planning costs no second scan. - `destroy_project_disk_object` — one object, typed confirmation. - `sweep_orphaned_snapshots` — exposed, so its report is finally visible. Safety is structural: `reclaim` takes `ReclaimTarget`, which has no variant that can name a live project's data. Destructive work is a separate type reached only through `destroy`. No unfiltered prune is called anywhere, and nothing outside a `triple-c*` name or `triple-c.*` label is touched. Orphan detection subtracts ids from the project store and consults nothing else. From the daemon's side an idle live project and a deleted one are indistinguishable — volumes present, no container, no image — so inferring from container or image absence would offer a live project's credentials and transcripts for deletion. A store that loaded empty from an existing `projects.json` is treated as a failed load, not as "no projects", because `ProjectsStore::new()` recovers from a corrupt file by starting empty. Three things verified against a live Docker 29.7.2 rather than assumed: - Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which keeps every byte inside the daemon; bollard's import buffers a whole image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer synthetic came out 45.7 MB/1 layer. Image config does not survive, so it is replayed via create+commit, which round-trips a multi-line env var that a Dockerfile `ENV` could not. - Flattening breaks base-layer sharing, so the result carries its own copy of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a 4.72 GB shared base — compacting those costs ~4 GB. The bound now subtracts that penalty, such projects are not offered at all, and the run compares unique bytes and abandons a rewrite that would grow. - `docker builder prune` reports `Total:`, not `Total reclaimed space:`, so the first parser scored every prune as freeing nothing. The Windows/WSL2 note is mandatory and its copy lives in Rust beside the tests that pin it: pruning frees space inside `ext4.vhdx`, which never shrinks on its own, so C: does not change until the disk is compacted. Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and `projects/home/format.ts` and `migrationCopy.ts` now delegate to it with byte-identical output. Base 1000 by default, matching what Docker prints. Tests: 502 frontend (was 453), 365 Rust (was 322). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("defaults to base 1000, because that is what Docker prints", () => {
|
||||
// The Disk panel exists to explain `docker system df`, which formats with
|
||||
// `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying
|
||||
// 28.0 GB for the same build cache reads as a bug in the panel.
|
||||
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
|
||||
expect(formatBytes(1_000)).toBe("1.0 KB");
|
||||
expect(formatBytes(1_500_000)).toBe("1.5 MB");
|
||||
expect(formatBytes(12_273_392_374)).toBe("12.3 GB");
|
||||
});
|
||||
|
||||
it("leaves whole bytes without a decimal point", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(999)).toBe("999 B");
|
||||
});
|
||||
|
||||
it("reproduces the Project Home convention exactly under `binary`", () => {
|
||||
// Three modules import `projects/home/format.ts#formatBytes`, which is now
|
||||
// this function. Its output had to be byte-identical or re-pointing it
|
||||
// would have quietly changed every file listing in the app.
|
||||
expect(formatBytes(1023, { binary: true })).toBe("1023 B");
|
||||
expect(formatBytes(1024, { binary: true })).toBe("1.0 KB");
|
||||
expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB");
|
||||
expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB");
|
||||
expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB");
|
||||
});
|
||||
|
||||
it("reproduces the migration convention exactly by default", () => {
|
||||
// `migrationCopy.formatDataSize` is now a call to this, and its output is
|
||||
// asserted in MigrateContainerModal.test.tsx.
|
||||
expect(formatBytes(41_000_000)).toBe("41.0 MB");
|
||||
expect(formatBytes(3_800_000_000)).toBe("3.8 GB");
|
||||
});
|
||||
|
||||
it("labels binary units honestly when asked to", () => {
|
||||
expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB");
|
||||
expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB");
|
||||
});
|
||||
|
||||
it("climbs to TB rather than showing five-digit gigabytes", () => {
|
||||
expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB");
|
||||
});
|
||||
|
||||
it("renders an em dash for a size the daemon did not compute", () => {
|
||||
// Docker reports -1 for "not calculated" on shared sizes and volume ref
|
||||
// counts. `NaN GB` in the middle of a table is worse than nothing.
|
||||
expect(formatBytes(-1)).toBe("—");
|
||||
expect(formatBytes(NaN)).toBe("—");
|
||||
expect(formatBytes(Infinity)).toBe("—");
|
||||
});
|
||||
|
||||
it("honours a requested precision", () => {
|
||||
expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB");
|
||||
expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesDelta", () => {
|
||||
it("signs a figure that is being added rather than measured", () => {
|
||||
// "Next commit adds +868.0 MB" — the sign is what makes it read as a cost
|
||||
// about to be incurred rather than a size already on disk.
|
||||
expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB");
|
||||
expect(formatBytesDelta(0)).toBe("+0 B");
|
||||
});
|
||||
|
||||
it("does not sign an unknown", () => {
|
||||
expect(formatBytesDelta(-1)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesCeiling", () => {
|
||||
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
|
||||
// Every other figure in the Disk panel is measured. This one cannot be
|
||||
// known until the rewrite runs, and rendering it through a separate
|
||||
// function is what stops it being read as a guarantee.
|
||||
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
|
||||
});
|
||||
|
||||
it("refuses to imply a saving when there is no bound to give", () => {
|
||||
expect(formatBytesCeiling(0)).toBe("an unknown amount");
|
||||
expect(formatBytesCeiling(-1)).toBe("an unknown amount");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The one byte formatter.
|
||||
*
|
||||
* Before this existed the app had four of them — `projects/home/format.ts`,
|
||||
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
||||
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
||||
* unit labels and the precision. They are now expressed in terms of this.
|
||||
*
|
||||
* ## Why the default is base 1000
|
||||
*
|
||||
* The Disk panel exists to explain what `docker system df` reports, and Docker
|
||||
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
|
||||
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
|
||||
* same build cache would read as a bug in the panel. So decimal is the default
|
||||
* and binary is opt-in, rather than the other way round.
|
||||
*
|
||||
* Both existing conventions are preserved exactly, so re-pointing the old
|
||||
* call sites changed no rendered string:
|
||||
*
|
||||
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
||||
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
||||
* labels, what Project Home used
|
||||
* — technically a misnomer, but
|
||||
* it is the app's convention and
|
||||
* changing it is not this
|
||||
* feature's business)
|
||||
* - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly)
|
||||
*/
|
||||
|
||||
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
/** Divide by 1024 instead of 1000. */
|
||||
binary?: boolean;
|
||||
/** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */
|
||||
iec?: boolean;
|
||||
/** Decimal places above `B`. Bytes are always whole. */
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string {
|
||||
const { binary = false, iec = false, precision = 1 } = options;
|
||||
|
||||
// A negative or non-finite size is a bug upstream, not something to render as
|
||||
// `NaN GB` in the middle of a table. Docker reports -1 for "not computed",
|
||||
// and that is the case this actually catches.
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
||||
|
||||
const step = binary ? 1024 : 1000;
|
||||
const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS;
|
||||
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= step && unit < units.length - 1) {
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
|
||||
return unit === 0
|
||||
? `${Math.round(bytes)} ${units[0]}`
|
||||
: `${value.toFixed(precision)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than
|
||||
* measured. Used for "next commit adds …", which is the number that explains
|
||||
* why a snapshot grows.
|
||||
*/
|
||||
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
|
||||
const formatted = formatBytes(bytes, options);
|
||||
return formatted === "—" ? formatted : `+${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `up to 12.3 GB` / `nothing` — for a bound rather than a measurement.
|
||||
*
|
||||
* The Disk panel is careful about this distinction: every figure it shows is
|
||||
* measured except a compaction's yield, which cannot be known until it runs.
|
||||
* Rendering that one through a different function is what stops it being read
|
||||
* as a promise.
|
||||
*/
|
||||
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
|
||||
return `up to ${formatBytes(bytes, options)}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -349,3 +349,34 @@ export const rollbackMigration = (projectId: string) =>
|
||||
* app crash shows up here as phase "interrupted". */
|
||||
export const getMigrationState = (projectId: string) =>
|
||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
||||
|
||||
// Disk
|
||||
|
||||
/** Measure where the daemon's bytes have gone.
|
||||
*
|
||||
* **Expensive — keep it behind an explicit Scan button.** This is
|
||||
* `GET /system/df`, which walks every image, container and volume on the
|
||||
* daemon to compute shared-layer sizes, plus an `image_history` per image.
|
||||
* Seconds on a 100 GB store. Never call it on mount and never poll it. */
|
||||
export const getDockerDiskUsage = () => invoke<DiskUsageReport>("get_docker_disk_usage");
|
||||
|
||||
/** Classify what could be reclaimed, with measured bytes. Takes the report
|
||||
* from `getDockerDiskUsage` so re-planning costs no second scan. */
|
||||
export const listReclaimable = (report: DiskUsageReport) =>
|
||||
invoke<ReclaimPlan>("list_reclaimable", { report });
|
||||
|
||||
/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action,
|
||||
* so no selection built here can delete a live project's data. */
|
||||
export const reclaim = (targets: ReclaimTarget[]) =>
|
||||
invoke<ReclaimOutcome>("reclaim", { targets });
|
||||
|
||||
/** Delete one object that has no other copy. `confirmation` must be the
|
||||
* project's name, typed by the user. One target per call, never bulk. */
|
||||
export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) =>
|
||||
invoke<ReclaimResult>("destroy_project_disk_object", { target, confirmation });
|
||||
|
||||
/** Run the orphaned-snapshot sweep on demand and see its report — the same
|
||||
* sweep that runs at startup and after every recreation, whose result every
|
||||
* existing caller throws away. */
|
||||
export const sweepOrphanedSnapshots = () =>
|
||||
invoke<SnapshotSweepReport>("sweep_orphaned_snapshots");
|
||||
|
||||
@@ -817,3 +817,198 @@ export interface MigrationState {
|
||||
options: MigrationOptions;
|
||||
plan: MigrationPlan | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disk
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every
|
||||
// other IPC struct in this app.
|
||||
|
||||
/** One row of the per-project disk table. */
|
||||
export interface ProjectDiskRow {
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
snapshot_image: string;
|
||||
snapshot_exists: boolean;
|
||||
/** Total size of the snapshot image, base image included. */
|
||||
snapshot_bytes: number;
|
||||
/** Bytes shared with another image — almost always the base. */
|
||||
snapshot_shared_bytes: number;
|
||||
/** Layers stacked above the base image: **one per container recreation**.
|
||||
* This is the number that explains why a snapshot grows. */
|
||||
snapshot_commit_layers: number;
|
||||
/** Bytes those layers account for. `null` when the base image is gone and
|
||||
* the split cannot be measured — never a guess. */
|
||||
snapshot_above_base_bytes: number | null;
|
||||
container_exists: boolean;
|
||||
container_running: boolean;
|
||||
/** The writable layer, i.e. exactly what the next commit will add. */
|
||||
container_writable_bytes: number;
|
||||
home_volume_bytes: number;
|
||||
home_volume_present: boolean;
|
||||
config_volume_bytes: number;
|
||||
config_volume_present: boolean;
|
||||
total_bytes: number;
|
||||
migrating: boolean;
|
||||
}
|
||||
|
||||
export interface BaseImageRow {
|
||||
reference: string;
|
||||
bytes: number;
|
||||
shared_bytes: number;
|
||||
containers: number;
|
||||
is_labelled_base: boolean;
|
||||
}
|
||||
|
||||
/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies.
|
||||
* The vhdx copy comes from Rust so the wording cannot drift from the
|
||||
* constants its tests pin. */
|
||||
export interface HostStorage {
|
||||
docker_root_dir: string;
|
||||
operating_system: string;
|
||||
is_docker_desktop: boolean;
|
||||
is_windows_host: boolean;
|
||||
vhdx_applies: boolean;
|
||||
/** Empty unless `vhdx_applies`. */
|
||||
vhdx_note: string;
|
||||
vhdx_fix: string[];
|
||||
vhdx_fix_gui: string;
|
||||
}
|
||||
|
||||
export interface BuildCacheUsage {
|
||||
total_bytes: number;
|
||||
reclaimable_bytes: number;
|
||||
/** What a `--filter until=168h` prune would reach. */
|
||||
stale_bytes: number;
|
||||
/** `"buildx du"` or `"system df"` — `docker system df` under-reports build
|
||||
* cache, so which one produced the number is worth showing. */
|
||||
source: string;
|
||||
cli_error: string | null;
|
||||
}
|
||||
|
||||
/** A per-project volume whose project id is not in Triple-C's project store.
|
||||
*
|
||||
* **Not "a volume with no container".** From the daemon's side an idle live
|
||||
* project and a deleted one look identical — volumes present, no container,
|
||||
* nothing running — so only the project store can tell them apart. */
|
||||
export interface OrphanVolume {
|
||||
name: string;
|
||||
project_id: string;
|
||||
bytes: number;
|
||||
/** `"home"` or `"config"`. */
|
||||
role: string;
|
||||
/** When Docker created it. Evidence a user can recognise a project by; a
|
||||
* size and a UUID identify nothing. From `df()` metadata — volumes are
|
||||
* never mounted to inspect them, because `docker run -v` *creates* a
|
||||
* volume that does not exist. */
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */
|
||||
export interface DiskUsageReport {
|
||||
scanned_at: string;
|
||||
projects: ProjectDiskRow[];
|
||||
base_images: BaseImageRow[];
|
||||
base_images_bytes: number;
|
||||
orphan_image_bytes: number;
|
||||
orphan_image_count: number;
|
||||
orphan_volumes: OrphanVolume[];
|
||||
orphan_volume_bytes: number;
|
||||
/** Why orphan detection was suppressed, when it was. */
|
||||
orphan_volumes_unavailable: string | null;
|
||||
build_cache: BuildCacheUsage;
|
||||
images_total_bytes: number;
|
||||
containers_total_bytes: number;
|
||||
volumes_total_bytes: number;
|
||||
triple_c_total_bytes: number;
|
||||
host: HostStorage;
|
||||
}
|
||||
|
||||
/** Mirrors Rust `Safety` (serde snake_case). */
|
||||
export type ReclaimSafety = "safe" | "semi_safe";
|
||||
|
||||
/** Mirrors Rust `ReclaimTarget`, an internally tagged enum.
|
||||
*
|
||||
* This type **cannot express a destructive action** — that is
|
||||
* `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one.
|
||||
* The separation is structural on both sides on purpose. */
|
||||
export type ReclaimTarget =
|
||||
| { kind: "dangling_snapshots" }
|
||||
| { kind: "superseded_base_images" }
|
||||
| { kind: "build_cache"; all: boolean }
|
||||
| { kind: "migration_pins" }
|
||||
| { kind: "migration_staging" }
|
||||
| { kind: "probe_containers" }
|
||||
| { kind: "scrub_containers" }
|
||||
| { kind: "orphan_volume"; name: string }
|
||||
| { kind: "compact_snapshot"; project_id: string }
|
||||
| { kind: "clear_caches"; project_id: string; include_rustup: boolean };
|
||||
|
||||
/** Mirrors Rust `DestructiveTarget`. Every one of these deletes something with
|
||||
* no other copy, and needs the project's name typed to confirm. */
|
||||
export type DestructiveTarget =
|
||||
| { kind: "home_volume"; project_id: string }
|
||||
| { kind: "config_volume"; project_id: string }
|
||||
| { kind: "snapshot_image"; project_id: string }
|
||||
| { kind: "rollback_pin"; project_id: string; tag: string };
|
||||
|
||||
export interface ReclaimItem {
|
||||
target: ReclaimTarget;
|
||||
safety: ReclaimSafety;
|
||||
/** Reaches beyond Triple-C's own objects — true only for the build cache,
|
||||
* and the UI must say so. */
|
||||
daemon_wide: boolean;
|
||||
label: string;
|
||||
detail: string;
|
||||
bytes: number;
|
||||
/** `false` means `bytes` is a bound, not a measurement. Render it as
|
||||
* "up to …" — only snapshot compaction sets this. */
|
||||
bytes_are_exact: boolean;
|
||||
bytes_floor: number | null;
|
||||
/** Why this cannot run right now. */
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
export interface DestructiveItem {
|
||||
target: DestructiveTarget;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
label: string;
|
||||
/** Spelled out in full — this is the confirmation copy. */
|
||||
loses: string;
|
||||
bytes: number;
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
export interface ReclaimPlan {
|
||||
items: ReclaimItem[];
|
||||
/** Display only. `reclaim` cannot act on these. */
|
||||
destructive: DestructiveItem[];
|
||||
store_error: string | null;
|
||||
}
|
||||
|
||||
export interface ReclaimResult {
|
||||
target: ReclaimTarget;
|
||||
ok: boolean;
|
||||
freed_bytes: number;
|
||||
/** What was projected beforehand, for the one action that projects. */
|
||||
projected_bytes: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ReclaimOutcome {
|
||||
results: ReclaimResult[];
|
||||
total_freed_bytes: number;
|
||||
}
|
||||
|
||||
/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of
|
||||
* `[reference, error]` pairs — a Rust tuple serialises as an array. */
|
||||
export interface SnapshotSweepReport {
|
||||
removed: string[];
|
||||
reclaimed_bytes: number;
|
||||
/** Refused because a container is still built from them. Normal. */
|
||||
in_use: number;
|
||||
failed: [string, string][];
|
||||
unavailable: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user