Skip to content
File

Blob: src/client/islands/repo-admin/PackFilesCard.tsx

typescript220 lines
1import { useState } from "react";
2import { Check, ChevronDown, ChevronUp, Trash2, X } from "lucide-react";
3import { Card } from "@/client/components/ui/card";
4import { formatSampleBytes, shortValue } from "./format";
5import type { PackStat } from "./types";
6 
7export type PackFilesCardProps = {
8 packCount: number;
9 packStats: PackStat[];
10 pending: Record<string, boolean>;
11 removePack: (packName: string) => Promise<void>;
12};
13 
14function packKindLabel(kind: PackStat["kind"]): string {
15 switch (kind) {
16 case "compact":
17 return "compact";
18 case "receive":
19 return "receive";
20 default:
21 return "legacy";
22 }
23}
24 
25function PackRow({
26 packStat,
27 pending,
28 removePack,
29}: {
30 packStat: PackStat;
31 pending: Record<string, boolean>;
32 removePack: (packName: string) => Promise<void>;
33}) {
34 const packName = String(packStat.key).split("/").pop() || packStat.key;
35 const canDelete = packStat.state === "superseded";
36 // Mirror the Index cell's icon-only treatment. The .refs sidecar is either present,
37 // missing, or its status couldn't be determined — all descriptive context lives in
38 // the tooltip so the table stays visually consistent across rows.
39 const refStatus = packStat.refIndexStatus ?? "unknown";
40 const refSizeSuffix =
41 refStatus === "present" && typeof packStat.refIndexSize === "number"
42 ? ` (${formatSampleBytes(packStat.refIndexSize)})`
43 : "";
44 const refTitle =
45 refStatus === "present"
46 ? `Reference sidecar is present in R2${refSizeSuffix}`
47 : refStatus === "missing"
48 ? "Reference sidecar is missing from R2"
49 : "Reference sidecar status could not be checked";
50 
51 return (
52 <tr className="border-b border-zinc-100 dark:border-zinc-800">
53 <td className="py-2 font-mono text-xs">
54 <span title={packStat.key}>{shortValue(packName, 35)}</span>
55 </td>
56 <td className="py-2">
57 <span
58 className={`rounded px-2 py-1 text-xs ${
59 packStat.state === "active"
60 ? "bg-green-100 dark:bg-green-900"
61 : "bg-zinc-200 dark:bg-zinc-800"
62 }`}
63 >
64 {packStat.state}
65 </span>
66 </td>
67 <td className="py-2">{packKindLabel(packStat.kind)}</td>
68 <td className="py-2 text-right font-mono text-xs">{packStat.tier}</td>
69 <td className="py-2 text-right font-mono text-xs">{packStat.objectCount}</td>
70 <td className="py-2 text-right font-mono text-xs">{formatSampleBytes(packStat.packSize)}</td>
71 <td className="py-2 text-right font-mono text-xs">{formatSampleBytes(packStat.indexSize)}</td>
72 <td className="py-2 text-center">
73 {packStat.hasIndex ? (
74 <Check className="inline h-4 w-4 text-green-600 dark:text-green-400" aria-hidden="true" />
75 ) : (
76 <X className="inline h-4 w-4 text-red-600 dark:text-red-400" aria-hidden="true" />
77 )}
78 </td>
79 <td className="py-2 text-center" title={refTitle}>
80 {refStatus === "present" ? (
81 <Check className="inline h-4 w-4 text-green-600 dark:text-green-400" aria-hidden="true" />
82 ) : refStatus === "missing" ? (
83 <X className="inline h-4 w-4 text-red-600 dark:text-red-400" aria-hidden="true" />
84 ) : (
85 <span
86 className="inline-block font-mono text-xs text-zinc-500 dark:text-zinc-400"
87 aria-hidden="true"
88 >
89 ?
90 </span>
91 )}
92 </td>
93 <td className="py-2 text-center">
94 <button
95 type="button"
96 className="rounded p-1 text-red-600 transition-colors hover:bg-red-100 hover:text-red-800 disabled:cursor-not-allowed disabled:opacity-40 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
97 title={
98 canDelete
99 ? "Delete this superseded pack"
100 : "Active packs cannot be deleted during normal operation"
101 }
102 aria-label="Delete pack"
103 onClick={() => void removePack(packName)}
104 disabled={!canDelete || pending[`remove-pack:${packName}`]}
105 >
106 <Trash2 className="h-4 w-4" aria-hidden="true" />
107 </button>
108 </td>
109 </tr>
110 );
111}
112 
113export function PackFilesCard({ packCount, packStats, pending, removePack }: PackFilesCardProps) {
114 // Superseded rows are hidden by default so the handful of active packs aren't
115 // buried under compaction history. The toggle only appears when there's
116 // something to reveal.
117 const [showSuperseded, setShowSuperseded] = useState(false);
118 
119 if (packStats.length === 0 && packCount === 0) {
120 return null;
121 }
122 
123 if (packStats.length === 0 && packCount > 0) {
124 return (
125 <Card>
126 <h2 className="mb-4 text-xl font-semibold">Pack Catalog</h2>
127 <p className="text-zinc-600 dark:text-zinc-400">
128 {packCount} pack rows exist but detailed catalog data was not loaded.
129 </p>
130 </Card>
131 );
132 }
133 
134 const activeRows = packStats.filter((p) => p.state === "active");
135 const supersededRows = packStats.filter((p) => p.state === "superseded");
136 const hasSuperseded = supersededRows.length > 0;
137 const headerCount =
138 hasSuperseded && showSuperseded
139 ? `${activeRows.length} active, ${supersededRows.length} superseded`
140 : `${activeRows.length} active`;
141 
142 return (
143 <Card>
144 <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
145 <h2 className="text-xl font-semibold">Pack Catalog ({headerCount})</h2>
146 {hasSuperseded ? (
147 <button
148 type="button"
149 onClick={() => setShowSuperseded((prev) => !prev)}
150 className="inline-flex items-center gap-1.5 rounded border border-zinc-300 px-2.5 py-1 text-xs text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
151 aria-expanded={showSuperseded}
152 >
153 {showSuperseded ? (
154 <>
155 <ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
156 Hide superseded history
157 </>
158 ) : (
159 <>
160 <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
161 Show superseded history ({supersededRows.length})
162 </>
163 )}
164 </button>
165 ) : null}
166 </div>
167 <div className="overflow-x-auto">
168 <table className="w-full text-sm">
169 <thead>
170 <tr className="border-b border-zinc-200 dark:border-zinc-800">
171 <th className="py-2 text-left">Pack Name</th>
172 <th className="py-2 text-left">State</th>
173 <th className="py-2 text-left">Kind</th>
174 <th className="py-2 text-right">Tier</th>
175 <th className="py-2 text-right">Objects</th>
176 <th className="py-2 text-right">Pack Size</th>
177 <th className="py-2 text-right">Index Size</th>
178 <th className="py-2 text-center">Index</th>
179 <th className="py-2 text-center">Refs</th>
180 <th className="py-2 text-center">Actions</th>
181 </tr>
182 </thead>
183 <tbody>
184 {activeRows.map((packStat) => (
185 <PackRow
186 key={packStat.key}
187 packStat={packStat}
188 pending={pending}
189 removePack={removePack}
190 />
191 ))}
192 {showSuperseded && hasSuperseded ? (
193 <>
194 {activeRows.length > 0 ? (
195 <tr className="border-b border-zinc-200 dark:border-zinc-800">
196 <td
197 colSpan={10}
198 className="py-1 text-center text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
199 >
200 superseded history
201 </td>
202 </tr>
203 ) : null}
204 {supersededRows.map((packStat) => (
205 <PackRow
206 key={packStat.key}
207 packStat={packStat}
208 pending={pending}
209 removePack={removePack}
210 />
211 ))}
212 </>
213 ) : null}
214 </tbody>
215 </table>
216 </div>
217 </Card>
218 );
219}