// "FREE UP N GB" — which channels to move, given a number of bytes to reclaim. // // The operator's actual question on a disk that is 96 % full is not "which // channel is biggest" but "what is the shortest list I can move to get 200 GB // back". Answering it by hand means sorting the table by size, adding figures // in your head and ticking rows until the sum clears the target — which is // exactly the kind of arithmetic a computer should not be making a person do at // 3am on a nearly-full disk. // // PURE, like everything in views/: it takes rows and a target and returns // slugs. The page holds the rows, the deck ticks the boxes, and the existing // bulk Move action is what actually moves anything — this selects, it does not // act. // // THREE RULES, each of which is a refusal to guess: // // 1. ONLY CHANNELS THAT ARE IN PLACE AND NOT ALREADY MOVING. A channel on the // platter frees nothing on the disk being emptied; it would be counted as // progress and deliver none. A channel MID-RELOCATION is the same problem // with a worse ending: `.relocating.json` is present, the bulk move refuses // it by name, and proposing it puts a row in the deck that is guaranteed to // skip — so the "N GB selected" figure is a promise the Move cannot keep. // They are excluded and counted, and the note says which. // 2. A CHANNEL WITH NO MEASUREMENT IS NOT A CHANNEL WITH ZERO BYTES. A // snapshot written before `totalMediaBytes` existed carries no figure, and // ranking it as empty would leave the largest channel on the disk at the // bottom of the list. They are excluded and COUNTED, so the deck can say // "3 channels have no size yet — refresh their reports" instead of // silently proposing a worse answer. // 3. LARGEST FIRST, AND THE LAST ONE OVERSHOOTS. The point is the shortest // list, so the greedy order is the right one; the final pick will usually // carry the total past the target, and that is the honest outcome rather // than a subset-sum search for an exact fit nobody asked for. export type FreeUpCandidate = { slug: string; // `snapshot.totalMediaBytes`, or null when the report predates the field. bytes: number | null; // Its media is on the corpus volume — the disk being freed. inPlace: boolean; // A relocation marker is present: the media is in transition. The bulk move // refuses such a channel by name, so selecting it would put a guaranteed // skip in the deck and inflate the total. inTransition?: boolean; }; export type FreeUpSelection = { // Largest first, which is also the order they were picked in. slugs: string[]; // What the selection would move. bytes: number; // Bytes still wanted after every eligible channel was taken. 0 when the // target was met. shortfall: number; // Channels skipped for want of a measurement (rule 2). unmeasured: number; // In-place channels skipped because their media is already moving (rule 1). moving: number; // A sentence for the deck, built here so the two surfaces that could show it // cannot word it differently. note: string; }; export function selectToFreeBytes( candidates: ReadonlyArray, targetBytes: number, ): FreeUpSelection { const moving = candidates.filter((c) => c.inPlace && c.inTransition).length; const unmeasured = candidates.filter( (c) => c.inPlace && !c.inTransition && c.bytes === null, ).length; const eligible = candidates .filter( (c): c is FreeUpCandidate & { bytes: number } => c.inPlace && !c.inTransition && typeof c.bytes === "number" && c.bytes > 0, ) .sort((a, b) => b.bytes - a.bytes || a.slug.localeCompare(b.slug)); const slugs: string[] = []; let bytes = 0; if (targetBytes > 0) { for (const c of eligible) { if (bytes >= targetBytes) break; slugs.push(c.slug); bytes += c.bytes; } } const shortfall = Math.max(0, targetBytes - bytes); return { slugs, bytes, shortfall, unmeasured, moving, note: noteFor(slugs.length, shortfall, unmeasured, moving), }; } function gb(n: number): string { return `${(n / 1024 ** 3).toFixed(1)} GB`; } function noteFor( picked: number, shortfall: number, unmeasured: number, moving: number, ): string { const tail = (unmeasured > 0 ? ` ${unmeasured} channel(s) have no size in their report yet and were not considered — refresh them for a better answer.` : "") + (moving > 0 ? ` ${moving} channel(s) are mid-relocation and were not considered — the move would refuse them.` : ""); if (picked === 0 && shortfall > 0) { return `Nothing in place has a measured size to move.${tail}`; } if (shortfall > 0) { return ( `Selected all ${picked} measured in-place channel(s) — still ` + `${gb(shortfall)} short of the target.${tail}` ); } return `Selected ${picked} channel(s), largest first.${tail}`; }