Skip to content

Commit 7b42975

Browse files
author
John Donmoyer
committed
feat: add labels column, filter, and sort to sandbox dashboard
- Show a LABELS column in the sandbox table (space-separated key=value pairs, truncated with … at 24 chars) - Press l to cycle through label filters (same pattern as f for region) - Press o to cycle sort now includes label sort (alphabetical by first label, empty last) - Fix column header alignment — all headers now align with their data columns
1 parent 6df3292 commit 7b42975

1 file changed

Lines changed: 80 additions & 17 deletions

File tree

sandbox/dashboard.ts

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface SandboxInfo {
2323
created_at: Date;
2424
stopped_at: Date | null;
2525
cluster_hostname: string;
26+
labels?: Record<string, string>;
2627
}
2728

2829
interface OrgInfo {
@@ -39,7 +40,8 @@ interface DashboardState {
3940
loading: boolean;
4041
lastRefresh: Date;
4142
regionFilter: string | null;
42-
sortBy: "created" | "status" | "region";
43+
labelFilter: string | null;
44+
sortBy: "created" | "status" | "region" | "label";
4345
sortAsc: boolean;
4446
mode: "normal" | "extend" | "org";
4547
statusMessage: string | null;
@@ -134,6 +136,7 @@ function renderScreen(state: DashboardState): string {
134136
let summary = ` ${total} total`;
135137
if (parts.length > 0) summary += ` — ${parts.join(", ")}`;
136138
if (state.regionFilter) summary += dim(` (region: ${state.regionFilter})`);
139+
if (state.labelFilter) summary += dim(` (label: ${state.labelFilter})`);
137140
const sortArrow = state.sortAsc ? "↑" : "↓";
138141
summary += dim(` Sort: ${state.sortBy} ${sortArrow}`);
139142

@@ -191,8 +194,8 @@ function renderScreen(state: DashboardState): string {
191194
}
192195
} else {
193196
// Normal sandbox table
194-
const headers = ["", "ID", "REGION", "STATUS", "UPTIME", "CREATED"];
195-
const colWidths = [2, 16, 10, 10, 10, 22];
197+
const headers = ["", "ID", "REGION", "STATUS", "UPTIME", "CREATED", "LABELS"];
198+
const colWidths = [2, 16, 10, 10, 10, 22, 24];
196199

197200
// Calculate column widths based on actual data
198201
for (const sandbox of displayList) {
@@ -201,19 +204,21 @@ function renderScreen(state: DashboardState): string {
201204
colWidths[2] = Math.max(colWidths[2], region.length);
202205
}
203206

204-
const headerLine = " " + headers.map((h, i) =>
205-
dim(h.padEnd(colWidths[i]))
207+
const headerLine = " " + headers.slice(1).map((h, i) =>
208+
dim(h.padEnd(colWidths[i + 1]))
206209
).join(" ");
207210
lines.push(headerLine);
208211

209212
// Sandbox rows — render the filtered+sorted list
210213
if (displayList.length === 0 && !state.loading) {
211214
lines.push("");
212-
if (state.regionFilter) {
215+
if (state.regionFilter || state.labelFilter) {
216+
const filterDesc = [
217+
state.regionFilter ? `region "${state.regionFilter}"` : null,
218+
state.labelFilter ? `label "${state.labelFilter}"` : null,
219+
].filter(Boolean).join(", ");
213220
lines.push(
214-
dim(
215-
` No sandboxes in region "${state.regionFilter}". Press f to cycle filters.`,
216-
),
221+
dim(` No sandboxes match ${filterDesc}. Press f/l to cycle filters.`),
217222
);
218223
} else {
219224
lines.push(
@@ -258,11 +263,20 @@ function renderScreen(state: DashboardState): string {
258263
Math.max(0, colWidths[3] - stripAnsiCode(statusText).length),
259264
);
260265

266+
const labelEntries = Object.entries(sandbox.labels ?? {});
267+
let labelsStr = labelEntries.length > 0
268+
? labelEntries.map(([k, v]) => `${k}=${v}`).join(" ")
269+
: "—";
270+
if (labelsStr.length > colWidths[6]) {
271+
labelsStr = labelsStr.slice(0, colWidths[6] - 1) + "…";
272+
}
273+
261274
const row = ` ${marker} ${sandbox.id.padEnd(colWidths[1])} ` +
262275
`${region.padEnd(colWidths[2])} ` +
263276
`${statusPadded} ` +
264277
`${uptime.padEnd(colWidths[4])} ` +
265-
`${created}`;
278+
`${created.padEnd(colWidths[5])} ` +
279+
`${labelsStr}`;
266280

267281
if (isSelected) {
268282
lines.push(INVERSE + row + RESET_STYLE);
@@ -320,6 +334,7 @@ function renderScreen(state: DashboardState): string {
320334
bold("e") + dim(" Extend"),
321335
bold("c") + dim(" Copy ID"),
322336
bold("f") + dim(" Filter"),
337+
bold("l") + dim(" Label"),
323338
bold("o/O") + dim(" Sort"),
324339
bold("t") + dim(" Org"),
325340
bold("r") + dim(" Refresh"),
@@ -334,7 +349,7 @@ function renderScreen(state: DashboardState): string {
334349
// Returns a new array — doesn't modify the original.
335350
function sortSandboxes(
336351
sandboxes: SandboxInfo[],
337-
sortBy: "created" | "status" | "region",
352+
sortBy: "created" | "status" | "region" | "label",
338353
asc: boolean,
339354
): SandboxInfo[] {
340355
const sorted = [...sandboxes];
@@ -361,6 +376,19 @@ function sortSandboxes(
361376
)
362377
);
363378
break;
379+
case "label":
380+
// Alphabetical by first label key=value; empty labels sort last
381+
sorted.sort((a, b) => {
382+
const aLabel = Object.entries(a.labels ?? {})[0];
383+
const bLabel = Object.entries(b.labels ?? {})[0];
384+
const aStr = aLabel ? `${aLabel[0]}=${aLabel[1]}` : "";
385+
const bStr = bLabel ? `${bLabel[0]}=${bLabel[1]}` : "";
386+
if (!aStr && !bStr) return 0;
387+
if (!aStr) return 1;
388+
if (!bStr) return -1;
389+
return aStr.localeCompare(bStr);
390+
});
391+
break;
364392
}
365393
if (asc) sorted.reverse();
366394
return sorted;
@@ -399,6 +427,7 @@ async function* readKeypress(): AsyncGenerator<string> {
399427
else if (byte === 0x6f) yield "o"; // Order/sort
400428
else if (byte === 0x4f) yield "O"; // Toggle sort direction
401429
else if (byte === 0x63) yield "c"; // Copy
430+
else if (byte === 0x6c) yield "l"; // Label filter
402431
else if (byte === 0x74) yield "t"; // Team/org picker
403432
else if (byte === 0x0d) yield "enter"; // Enter/Return
404433
else if (byte === 0x31) yield "1"; // Extend presets
@@ -421,10 +450,17 @@ async function* readKeypress(): AsyncGenerator<string> {
421450
// Returns the list of sandboxes after applying the region filter.
422451
// Used by both the key handlers (for navigation bounds) and renderScreen.
423452
function getFilteredSandboxes(state: DashboardState): SandboxInfo[] {
424-
if (state.regionFilter === null) return state.sandboxes;
425-
return state.sandboxes.filter(
426-
(s) => s.cluster_hostname.split(".")[0] === state.regionFilter,
427-
);
453+
let list = state.sandboxes;
454+
if (state.regionFilter !== null) {
455+
list = list.filter(
456+
(s) => s.cluster_hostname.split(".")[0] === state.regionFilter,
457+
);
458+
}
459+
if (state.labelFilter !== null) {
460+
const [key, value] = state.labelFilter.split("=");
461+
list = list.filter((s) => s.labels?.[key] === value);
462+
}
463+
return list;
428464
}
429465

430466
// Gets the sandbox that's currently highlighted, accounting for the region filter.
@@ -539,6 +575,7 @@ async function runDashboard(
539575
loading: true,
540576
lastRefresh: new Date(),
541577
regionFilter: null,
578+
labelFilter: null,
542579
sortBy: "created",
543580
sortAsc: false,
544581
mode: "normal",
@@ -682,6 +719,7 @@ async function runDashboard(
682719
state.org = selected.slug;
683720
state.selectedIndex = 0;
684721
state.regionFilter = null;
722+
state.labelFilter = null;
685723
state.mode = "normal";
686724
await refreshAndRender();
687725
} else if (key === "escape") {
@@ -771,12 +809,37 @@ async function runDashboard(
771809
} else {
772810
state.selectedIndex = 0;
773811
}
812+
} else if (key === "l") {
813+
// Cycle label filter: all → label1 → label2 → ... → all
814+
const labelPairs = [
815+
...new Set(
816+
state.sandboxes.flatMap((s) =>
817+
Object.entries(s.labels ?? {}).map(([k, v]) => `${k}=${v}`)
818+
),
819+
),
820+
].sort();
821+
822+
if (state.labelFilter === null) {
823+
if (labelPairs.length > 0) state.labelFilter = labelPairs[0];
824+
} else {
825+
const idx = labelPairs.indexOf(state.labelFilter);
826+
state.labelFilter = idx < labelPairs.length - 1
827+
? labelPairs[idx + 1]
828+
: null;
829+
}
830+
831+
// Clamp selection to filtered list
832+
const filteredAfterLabel = getFilteredSandboxes(state);
833+
state.selectedIndex = filteredAfterLabel.length > 0
834+
? Math.min(state.selectedIndex, filteredAfterLabel.length - 1)
835+
: 0;
774836
} else if (key === "o") {
775-
// Cycle sort: created → status → region → created
776-
const order: Array<"created" | "status" | "region"> = [
837+
// Cycle sort: created → status → region → label → created
838+
const order: Array<"created" | "status" | "region" | "label"> = [
777839
"created",
778840
"status",
779841
"region",
842+
"label",
780843
];
781844
const idx = order.indexOf(state.sortBy);
782845
state.sortBy = order[(idx + 1) % order.length];

0 commit comments

Comments
 (0)