-
Notifications
You must be signed in to change notification settings - Fork 4
feat(databases): tree nav, database overview, and shared table actions #1471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dawsontoth
wants to merge
2
commits into
stage
Choose a base branch
from
claude/databases-nav-redesign-1e5b1d
base: stage
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
109 changes: 109 additions & 0 deletions
109
src/features/instance/databases/components/DatabaseActionModals.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| /> | ||
| <DeleteTableModal onDeleted={onTableDropped} /> | ||
| <DeleteDatabaseModal onDeleted={onDatabaseDropped} /> | ||
| </> | ||
| ); | ||
| } | ||
165 changes: 165 additions & 0 deletions
165
src/features/instance/databases/components/DatabaseOverview.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.