Repository navigation
Expand file tree
/
Copy pathTableItem.tsx
More file actions
397 lines (380 loc) · 17 KB
/
Copy pathTableItem.tsx
File metadata and controls
397 lines (380 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
import React from "react";
import type { DetailedObject } from "@/lib/db/detailed-object";
import type { ProviderMetadata } from "@/hooks/use-provider-metadata";
import {
Search,
Table as TableIcon,
Play,
ChevronRight,
Funnel,
Hash,
EllipsisVertical,
Copy,
Trash2,
Code,
ChartColumn,
WandSparkles,
Wrench,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { offersTestDataGeneration } from "@/lib/db/object-kinds";
import {
declaredEntityOperations,
maintenanceControl,
offersCodeGeneration,
offersColumnProfiling,
offersCountQuery,
} from "@/lib/db/types";
import { formatRowCount, formatRowCountTitle } from "@/lib/db/utils/pool-manager";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { toast } from "sonner";
import { writeToClipboard } from "@/components/copy-button";
import { ColumnList } from "./ColumnList";
interface TableItemProps {
table: DetailedObject;
isExpanded: boolean;
onToggle: () => void;
// `labels` is itself optional on ProviderMetadata, so the indexed access already
// carries `undefined`; NonNullable keeps the `?` from restating it (#427).
labels?: NonNullable<ProviderMetadata["labels"]>;
capabilities?: ProviderMetadata["capabilities"];
isAdmin: boolean;
onTableClick?: (path: readonly string[]) => void;
onGenerateSelect?: (path: readonly string[]) => void;
onGenerateCount?: (path: readonly string[]) => void;
// ADDRESSES, one element per segment, the same shape `onTableClick` above already takes:
// the shell resolves the object by path, because a label is not unique (#789, Task 35).
onProfileTable?: (path: readonly string[]) => void;
onGenerateCode?: (path: readonly string[]) => void;
onGenerateTestData?: (path: readonly string[]) => void;
onOpenMaintenance?: (tab?: "global" | "tables" | "sessions", path?: readonly string[]) => void;
}
type TableItemCallbacks = Pick<
TableItemProps,
| "onTableClick"
| "onGenerateSelect"
| "onGenerateCount"
| "onProfileTable"
| "onGenerateCode"
| "onGenerateTestData"
| "onOpenMaintenance"
>;
/**
* What one rendering of the menu needs. The two call sites differ only in which
* primitives they pass (`DropdownMenu*` vs `ContextMenu*`), so everything else
* travels as one object rather than as a positional list.
*/
interface MenuItemsContext {
table: DetailedObject;
labels: TableItemProps["labels"];
capabilities: TableItemProps["capabilities"];
isAdmin: boolean;
callbacks: TableItemCallbacks;
copyToClipboard: (text: string, label: string) => void;
Item: React.ComponentType<{ onClick?: () => void; children: React.ReactNode }>;
Separator: React.ComponentType;
}
function renderMenuItems({
table,
labels,
capabilities,
isAdmin,
callbacks,
copyToClipboard,
Item,
Separator,
}: MenuItemsContext): React.ReactNode {
// Rows that are derived groupings are not addressable objects: a Redis `user:*`
// row is this server's summary of a key prefix, so profiling it has no target and
// the provider answers 400 (#427). Gate on the declared capability, never on
// connection.type. Absent capabilities read as "ordinary objects", matching the
// flag's own docblock; the gates below are what refuse capabilities that are unknown.
const rowsAreAddressable = capabilities?.tablesAreDerivedGroupings !== true;
// The three row actions ask exactly what the desktop tree asks in
// `src/components/object-tree/row-actions.ts`, so the two menus cannot disagree about
// what a provider declared (#1085, decision D-M). Profile needs a language
// `POST /api/db/profile` can profile as well as an addressable row. Generate Code needs a
// language whose columns model a stored record. Generate Test Data writes rows, so it asks
// `offersTestDataGeneration`: the row's KIND accepts row writes, and the engine declares
// `supportsTestDataGeneration` (#1468). This menu used to ask only the grouping flag for it,
// which offered the generator on every view and on every engine the tree withholds it from. Unknown
// capabilities offer none of the three: `/api/db/provider-meta` answers with nothing both
// while it is in flight and when it failed.
const offersProfile = rowsAreAddressable && offersColumnProfiling(capabilities);
const offersCode = offersCodeGeneration(capabilities);
const offersTestData = capabilities !== undefined && offersTestDataGeneration(capabilities, table.kind);
// The SAME question the monitoring Tables tab and the admin Operations tab ask, so
// that three surfaces cannot disagree about what a provider declared (#496). Gating
// on `supportsMaintenance` alone is what put "Vacuum Table" on ONE SQLite table
// while the Tables tab correctly withheld it - SQLite's VACUUM takes no target, and
// the page this item deep-links to therefore has no such control. Unknown
// capabilities read as a denial here too: `/api/db/provider-meta` answers with
// nothing both while it is in flight and when it failed.
//
// The vacuum item follows `vacuumActionOperation`, not the literal `vacuum`: four
// providers point that wording at an operation that is not a vacuum, and it is the
// operation - not the label - whose targeting decides whether a table can be named.
// The row's kind too, as the desktop tree asks it: Db2's RUNSTATS runs on a table, not a view (#786).
const analyzeControl = maintenanceControl(capabilities, "analyze", "perEntity", table.kind);
const vacuumControl = maintenanceControl(
capabilities,
labels?.vacuumActionOperation ?? "vacuum",
"perEntity",
table.kind,
);
// Every declared operation outside `MaintenanceType` that runs on one row, in declaration order (spec 3.11): the
// same list the desktop tree's row menu reads, so the two menus cannot disagree.
const entityOperations = declaredEntityOperations(capabilities, table.kind);
return (
<>
<Item onClick={() => callbacks.onTableClick?.(table.path)}>
<Play strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-green" />
{labels?.selectAction || "Select Top 50"}
</Item>
<Item onClick={() => callbacks.onGenerateSelect?.(table.path)}>
<Funnel strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-blue" />
{labels?.generateAction || "Generate Query"}
</Item>
{callbacks.onGenerateCount !== undefined && offersCountQuery(capabilities) && (
<Item onClick={() => callbacks.onGenerateCount?.(table.path)}>
<Hash strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-blue" />
{"Generate Count Query"}
</Item>
)}
<Item onClick={() => copyToClipboard(table.name, `${labels?.entityName || "Table"} name`)}>
<Copy strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-muted-foreground" />
{"Copy Name"}
</Item>
{/* One rule heads the three row actions below, so it is drawn only when one of them is:
a PromQL row, or a menu whose capabilities are still unknown, offers none, and a rule
with nothing after it would end the menu on a line (#1085). */}
{(offersProfile || offersCode || offersTestData) && <Separator />}
{offersProfile && (
<Item onClick={() => callbacks.onProfileTable?.(table.path)}>
<ChartColumn strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-cyan" />
{"Profile Table"}
</Item>
)}
{offersCode && (
<Item onClick={() => callbacks.onGenerateCode?.(table.path)}>
<Code strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-purple" />
{"Generate Code"}
</Item>
)}
{offersTestData && (
<Item onClick={() => callbacks.onGenerateTestData?.(table.path)}>
<WandSparkles strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-amber" />
{"Generate Test Data"}
</Item>
)}
{/* A PER-ROW maintenance action needs an addressable row AND an engine with
maintenance to run: both items call `onOpenMaintenance("tables", table.path)`,
and for a derived grouping there is no such object to name — which is exactly
the dead end #427 reported for Redis "Key Info".
The rest of the condition is the same dead end reached the other way, and the
#427 gate reached only its first case. Measured in the browser on 2026-08-19
against Elasticsearch 9.1.4: an index IS addressable, so both items rendered
on an engine that declares `supportsMaintenance: false`, and the page they
open gates its Global Operations card on that same capability — so clicking
"Merge Segments" landed on a page with no maintenance controls, no error and
no explanation. `maintenanceControl` above closes the rest of it: an engine
that declares the operation but not for a single table (SQLite's VACUUM) is
the same dead end with the capability flag switched on (#496).
Global maintenance is unaffected wherever an engine has any: it lives on the
admin Operations page and still runs there.
What this gate CANNOT answer is the rest of U22: both items are deep links, and the
destination renders a per-table control only for a ROW it has statistics for. Nothing
here knows whether one will arrive: `DetailedObject.rowCount` and `.size` are optional and
come from the schema read, not from the monitoring statistics the destination lists, and
no declared capability says whether an engine publishes per-table figures - so
withholding the link would need a new capability flag this repo does not want. The
destinations were made to say so instead - `TableMaintenanceUnreachableNote` in
src/components/admin/tabs/OperationsTab.tsx, where an admin's link actually lands
(Studio.tsx `openMaintenance`), and `MaintenanceUnattachableNote` in
src/components/monitoring/tabs/TablesTab.tsx for the /monitoring panel. Each is the only
reader that knows whether a row turned up, and its answer is testable against what
actually renders rather than against what was declared. */}
{isAdmin &&
rowsAreAddressable &&
(analyzeControl.offered || vacuumControl.offered || entityOperations.length > 0) && (
<>
<Separator />
{analyzeControl.offered && (
<Item onClick={() => callbacks.onOpenMaintenance?.("tables", table.path)}>
<Search strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-amber" />
{analyzeControl.label ?? labels?.analyzeAction ?? "Analyze Table"}
</Item>
)}
{vacuumControl.offered && (
<Item onClick={() => callbacks.onOpenMaintenance?.("tables", table.path)}>
<Trash2 strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-blue" />
{vacuumControl.label ?? labels?.vacuumAction ?? "Vacuum Table"}
</Item>
)}
{entityOperations.map((operation) => (
<Item key={operation.type} onClick={() => callbacks.onOpenMaintenance?.("tables", table.path)}>
<Wrench strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-brand" />
{operation.label}
</Item>
))}
</>
)}
</>
);
}
export const TableItem = React.memo(function TableItem({
table,
isExpanded,
onToggle,
labels,
capabilities,
isAdmin,
onTableClick,
onGenerateSelect,
onGenerateCount,
onProfileTable,
onGenerateCode,
onGenerateTestData,
onOpenMaintenance,
}: TableItemProps) {
const copyToClipboard = (text: string, label: string) => {
// The toast waits for the write to report an outcome (B43). It used to fire in the
// same statement that started it, which announced a copy that never happened over
// plain HTTP off loopback — `navigator.clipboard` is undefined there, and several
// distribution channels ship exactly that way.
void writeToClipboard(text).then((copied) => {
if (copied) toast.success(`${label} copied to clipboard`);
else toast.error(`Could not copy ${label} — select the text and copy it yourself`);
});
};
const callbacks = {
onTableClick,
onGenerateSelect,
onGenerateCount,
onProfileTable,
onGenerateCode,
onGenerateTestData,
onOpenMaintenance,
};
const actionsLabel = `Actions for ${table.name}`;
return (
<div className="group flex flex-col">
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={cn(
"flex items-center gap-1.5 px-2 rounded-md transition-all",
isExpanded ? "bg-accent/50" : "hover:bg-accent/30",
)}
>
<button
type="button"
aria-expanded={isExpanded}
className="flex items-center gap-1.5 flex-1 min-w-0 py-1.5 cursor-pointer text-left"
onClick={onToggle}
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }} className="shrink-0">
<ChevronRight strokeWidth={1.5} className="w-3.5 h-3.5 text-muted-foreground" />
</motion.div>
<TableIcon
className={cn(
"w-3.5 h-3.5 shrink-0 transition-colors",
isExpanded ? "text-brand" : "text-muted-foreground group-hover:text-foreground",
)}
/>
<span
className={cn(
"truncate min-w-0 flex-1 text-xs font-medium transition-colors",
isExpanded ? "text-foreground" : "text-muted-foreground group-hover:text-foreground",
)}
>
{table.name}
</span>
</button>
<div
title={table.rowCount === undefined ? undefined : formatRowCountTitle(table.rowCount)}
// Stacked where hover swaps them, side by side where the button is always shown.
className="shrink-0 relative w-8 h-6 flex items-center justify-center [@media(hover:none)]:w-auto [@media(hover:none)]:gap-1"
>
{table.rowCount !== undefined && (
<span
title={formatRowCountTitle(table.rowCount)}
className="absolute inset-0 flex items-center justify-center text-[0.625rem] font-mono text-muted-foreground/70 whitespace-nowrap opacity-100 group-hover:opacity-0 transition-opacity pointer-events-none [@media(hover:none)]:static"
>
{formatRowCount(table.rowCount)}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={actionsLabel}
title={actionsLabel}
className="absolute inset-0 w-full h-full opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 [@media(hover:none)]:static [@media(hover:none)]:w-6 focus-within:opacity-100 transition-opacity hover:bg-accent flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical
strokeWidth={1.5}
className="w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{renderMenuItems({
table,
labels,
capabilities,
isAdmin,
callbacks,
copyToClipboard,
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{renderMenuItems({
table,
labels,
capabilities,
isAdmin,
callbacks,
copyToClipboard,
Item: ContextMenuItem,
Separator: ContextMenuSeparator,
})}
</ContextMenuContent>
</ContextMenu>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<ColumnList columns={table.columns} indexes={table.indexes} />
</motion.div>
)}
</AnimatePresence>
</div>
);
});