Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function FileTreeContextMenu({
}) {
const { setOpenedEntry, setFocusedItem, setSelectedItems, selectedItems } = useEditorView();
const [target, setTarget] = useState<ContextTarget | undefined>(undefined);
// Bumped to the cursor position on every right-click so the content remounts and re-anchors there
// (see onContextMenu) -- without it Radix leaves an already-open menu at its first position.
const [anchorKey, setAnchorKey] = useState('');
const { canRename, canAddEntries, canDeleteEntry, canDownload, canRedeploy } = useEntryActions(target?.entry);

const focusTarget = useCallback((next: ContextTarget) => {
Expand Down Expand Up @@ -105,6 +108,10 @@ export function FileTreeContextMenu({
// multi-delete still spans every selected row); otherwise target just this one.
const next: ContextTarget = { entry, id, selection: selectedItems.includes(id) ? selectedItems : [id] };
setTarget(next);
// Radix keeps an already-open context menu anchored to its first position (its exit animation
// prevents a re-measure on reopen), so remount the content -- keyed by the cursor point -- to
// force it to re-anchor where you actually right-clicked.
setAnchorKey(`${event.clientX},${event.clientY}`);
}, [items, selectedItems]);

// Re-assert the target as the action fires. This runs in the same React batch
Expand Down Expand Up @@ -139,7 +146,11 @@ export function FileTreeContextMenu({
</div>
</ContextMenuTrigger>
{target && (
<ContextMenuContent className="min-w-44" onCloseAutoFocus={event => event.preventDefault()}>
<ContextMenuContent
key={anchorKey}
className="min-w-44"
onCloseAutoFocus={event => event.preventDefault()}
>
<ContextMenuItem onSelect={() => copy(target.entry.name)}>
<CopyIcon />
Copy Name
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { DirectoryEntry } from '@/features/instance/applications/context/directoryEntry';
import type { FileEntry } from '@/features/instance/applications/context/fileEntry';
import { cn } from '@/lib/cn';
import type { TreeItem } from 'react-complex-tree';
import type { TreeItemRenderContext } from 'react-complex-tree/src/types';
Expand All @@ -22,7 +20,9 @@ import type { TreeItemRenderContext } from 'react-complex-tree/src/types';
* class) so the visuals are unchanged. The enlarged hit area lives in CSS.
*/
export function ItemArrow({ item, context }: {
item: TreeItem<DirectoryEntry | FileEntry | undefined>;
// Data-agnostic on purpose: reads only `item.isFolder`, so both the applications and databases
// trees reuse it. (Typed as the library default `TreeItem` = `TreeItem<any>`.)
item: TreeItem;
context: TreeItemRenderContext;
}) {
if (!item.isFolder) {
Expand Down
109 changes: 109 additions & 0 deletions src/features/instance/databases/components/DatabaseActionModals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal';
import { CreateNewTableModal } from '@/features/instance/databases/modals/CreateNewTableModal';
import { DeleteDatabaseModal } from '@/features/instance/databases/modals/DeleteDatabaseModal';
import { DeleteTableModal } from '@/features/instance/databases/modals/DeleteTableModal';
import { ImportDataModal } from '@/features/instance/databases/modals/ImportDataModal';
import { InstanceDatabaseMap } from '@/integrations/api/api.patch';
import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher';
import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useRouter } from '@tanstack/react-router';
import { useCallback } from 'react';

/**
* Single, always-mounted home for every database/table action modal. The tree context menu and the
* right-pane toolbar both fire the same target-carrying watched values (see `watchedValueKeys.ts`), so
* an action can target ANY database/table -- not just the currently-open one -- and there is exactly
* one instance of each modal. Mounted unconditionally by the Databases page (once the map loads) so
* Drop Database / Create Table work even when no table is open or a database is empty.
*/
export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabaseMap?: InstanceDatabaseMap }) {
const params: {
clusterId?: string;
instanceId?: string;
organizationId?: string;
databaseName?: string;
tableName?: string;
} = useParams({ strict: false });
const navigate = useNavigate();
const router = useRouter();
const queryClient = useQueryClient();
const instanceParams = useInstanceClientIdParams();

const goToTable = useCallback((databaseName?: string, tableName?: string) => {
void navigate({ to: buildAbsoluteLinkToDatabasePage({ ...params, databaseName, tableName }) });
}, [navigate, params]);

const { value: createTarget } = useWatchedValue('ShowCreateTable', false);
const { value: addTarget } = useWatchedValue('ShowAddTableRecords', false);
const { value: importTarget } = useWatchedValue('ShowImportData', false);

const addInstanceTable = addTarget
? instanceDatabaseMap?.[addTarget.databaseName]?.[addTarget.tableName]
: undefined;

const onImported = useCallback(async (databaseName: string, tableName: string) => {
// The import may have created a new table (or even database), so refresh the tree too.
await queryClient.invalidateQueries({
queryKey: [instanceParams.entityId, 'describe_all'],
refetchType: 'all',
});
goToTable(databaseName, tableName);
await router.invalidate();
}, [queryClient, instanceParams.entityId, goToTable, router]);

// After a drop, only navigate when the dropped entity is the one currently open -- dropping some
// other tree item just needs the `describe_all` invalidation (done by the modal) to rebuild the tree.
const onTableDropped = useCallback(({ databaseName, tableName }: { databaseName: string; tableName: string }) => {
if (params.databaseName === databaseName && params.tableName === tableName) {
goToTable(databaseName, undefined); // fall back to the database overview
}
}, [params.databaseName, params.tableName, goToTable]);
const onDatabaseDropped = useCallback(({ databaseName }: { databaseName: string }) => {
if (params.databaseName === databaseName) {
goToTable(undefined, undefined); // the page re-picks the first remaining database
}
}, [params.databaseName, goToTable]);

return (
<>
<CreateNewTableModal
key={createTarget ? `create-${createTarget.databaseName ?? ''}` : 'create-closed'}
isModalOpen={!!createTarget}
setIsModalOpen={open => setWatchedValue('ShowCreateTable', open ? (createTarget || {}) : false)}
databaseName={createTarget ? createTarget.databaseName : undefined}
onCreated={goToTable}
/>
{addTarget && addInstanceTable && (
<AddTableRowModal
key={`add-${addTarget.databaseName}/${addTarget.tableName}`}
instanceTable={addInstanceTable}
isModalOpen
setIsModalOpen={open => {
if (!open) {
setWatchedValue('ShowAddTableRecords', false);
}
}}
refreshTable={() =>
void queryClient.invalidateQueries({
queryKey: [instanceParams.entityId, addTarget.databaseName, addTarget.tableName],
})}
/>
)}
<ImportDataModal
key={importTarget
? `import-${importTarget.databaseName ?? ''}/${importTarget.tableName ?? ''}`
: 'import-closed'}
isModalOpen={!!importTarget}
setIsModalOpen={open => setWatchedValue('ShowImportData', open ? (importTarget || {}) : false)}
instanceDatabaseMap={instanceDatabaseMap}
databaseName={importTarget ? importTarget.databaseName : undefined}
tableName={importTarget ? importTarget.tableName : undefined}
onImported={onImported}
/>
Comment thread
dawsontoth marked this conversation as resolved.
<DeleteTableModal onDeleted={onTableDropped} />
<DeleteDatabaseModal onDeleted={onDatabaseDropped} />
</>
);
}
165 changes: 165 additions & 0 deletions src/features/instance/databases/components/DatabaseOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdownMenu';
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
import { TableRowContextMenu } from '@/features/instance/databases/components/TableRowContextMenu';
import { formatBytes } from '@/features/instance/status/analytics/lib/time';
import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions';
import { InstanceDatabaseMap } from '@/integrations/api/api.patch';
import { getDescribeAllQueryOptions } from '@/integrations/api/instance/database/getDescribeAll';
import { setWatchedValue } from '@/lib/events/watcher';
import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from '@tanstack/react-router';
import { CloudUploadIcon, EllipsisIcon, PlusIcon, Trash2Icon } from 'lucide-react';
import { useCallback } from 'react';

function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border dark:border-grey-700 p-4">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-2xl font-medium">{value}</div>
</div>
);
}

export function DatabaseOverview({ instanceDatabaseMap, databaseName }: {
instanceDatabaseMap?: InstanceDatabaseMap;
databaseName: string;
}) {
const params: {
clusterId?: string;
instanceId?: string;
organizationId?: string;
} = useParams({ strict: false });
const navigate = useNavigate();
const instanceParams = useInstanceClientIdParams();
const canManage = useInstanceBrowseManagePermission();

const tables = instanceDatabaseMap?.[databaseName] ?? {};
const tableNames = Object.keys(tables).sort();

// Sizes are already in the fast (count-skipping) map. Record counts are not, so fetch describe_all
// WITH counts separately (distinct cache key) and backfill them as they arrive.
const { data: countedMap } = useQuery(getDescribeAllQueryOptions({ ...instanceParams }));
const countedTables = countedMap?.[databaseName];

// Every table in a database shares the same underlying store, so db_size/db_audit_size are the same
// on each -- read them off any table.
const anyTable = tableNames.length ? tables[tableNames[0]] : undefined;
const dbSize = anyTable?.db_size ?? 0;
const auditSize = anyTable?.db_audit_size ?? 0;
const totalRecords = countedTables
? Object.values(countedTables).reduce((sum, table) => sum + (table.record_count ?? 0), 0)
: undefined;

const goToTable = useCallback((tableName: string) => {
void navigate({ to: buildAbsoluteLinkToDatabasePage({ ...params, databaseName, tableName }) });
}, [navigate, params, databaseName]);

return (
<div className="pt-15 pb-4 pr-4">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 pb-6">
<div className="flex items-center gap-2 min-w-0">
<h1 className="text-3xl truncate">{databaseName}</h1>
</div>
{canManage && (
<div className="flex space-x-2 shrink-0">
<Button
variant="positiveOutline"
onClick={() => setWatchedValue('ShowCreateTable', { databaseName })}
>
<PlusIcon />
<span>Create a Table</span>
</Button>
<Button
variant="positiveOutline"
onClick={() => setWatchedValue('ShowImportData', { databaseName })}
>
<CloudUploadIcon />
<span>Import Data</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisIcon aria-label="Database options" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
className="focus:bg-red/70 focus:text-white"
onClick={() => setWatchedValue('ShowDeleteDatabase', { databaseName })}
>
<Trash2Icon />
Drop Database
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>

<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 pb-6">
<Stat label="Tables" value={tableNames.length.toLocaleString()} />
<Stat label="Records" value={totalRecords === undefined ? '…' : totalRecords.toLocaleString()} />
<Stat label="Size" value={formatBytes(dbSize)} />
<Stat label="Audit Size" value={formatBytes(auditSize)} />
</div>

{tableNames.length === 0
? (
<div className="rounded-md border border-border dark:border-grey-700 p-8 text-center text-muted-foreground">
<p className="pb-2">This database has no tables yet.</p>
{canManage && <p>Use “Create a Table” above, or right-click the database in the sidebar.</p>}
</div>
)
: (
<TableRowContextMenu databaseName={databaseName} instanceDatabaseMap={instanceDatabaseMap}>
<div className="rounded-md border border-border dark:border-grey-700 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground border-b border-border dark:border-grey-700">
<th className="p-3 font-medium">Table</th>
<th className="p-3 font-medium text-right">Records</th>
<th className="p-3 font-medium text-right">Size</th>
<th className="p-3 font-medium text-right">Columns</th>
<th className="p-3 font-medium">Primary Key</th>
<th className="p-3 font-medium">Last Updated</th>
</tr>
</thead>
<tbody>
{tableNames.map((tableName) => {
const table = tables[tableName];
const counted = countedTables?.[tableName];
const primaryKey = table.primary_key ?? table.hash_attribute ?? '—';
const recordCount = counted?.record_count;
const recordLabel = recordCount === undefined
? '…'
: `${counted?.estimated_record_range ? '~' : ''}${recordCount.toLocaleString()}`;
return (
<tr
key={tableName}
data-table-name={tableName}
onClick={() => goToTable(tableName)}
className="border-b border-border dark:border-grey-700 last:border-0 cursor-pointer hover:bg-accent dark:hover:bg-grey-700/80"
>
<td className="p-3 font-medium">{tableName}</td>
<td className="p-3 text-right tabular-nums">{recordLabel}</td>
<td className="p-3 text-right tabular-nums">{formatBytes(table.table_size ?? 0)}</td>
<td className="p-3 text-right tabular-nums">{table.attributes.length.toLocaleString()}</td>
<td className="p-3">{primaryKey}</td>
<td className="p-3 text-muted-foreground">
{table.last_updated_record
? new Date(table.last_updated_record).toLocaleString()
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</TableRowContextMenu>
)}
</div>
);
}
Loading