Let a super admin read the database from the app
A new super admin settings page, /admin/settings/database, lists every table with its record count and size, and one page of the open table's records newest key first. Values are cut inside SQL (400 characters, with a flag saying so) and a record opens whole in a modal, so a table of long texts reads without shipping it all. A search matches every column's text, word by word or as a phrase, which is the one thing that also works on a virtual table. Nothing in it writes: the catalog, the records and the single record are all reads, GET only.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import type { DatabaseSearchMode } from "@/src/types";
|
||||
|
||||
export type DatabaseSearchModeOption = {
|
||||
value: DatabaseSearchMode;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a search matches, in the order the picker offers them: every word is
|
||||
* the useful default (it finds a message by two words it holds wherever
|
||||
* they sit), the phrase is for a run of words that has to stay together.
|
||||
*/
|
||||
export const DatabaseSearchModes: DatabaseSearchModeOption[] = [
|
||||
{ value: "every_word", title: "Every word" },
|
||||
{ value: "phrase", title: "The phrase" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A mode read from the address, or the default when it isn't one of them.
|
||||
*/
|
||||
export function parseDatabaseSearchMode({ value }: { value?: string | null }): DatabaseSearchMode {
|
||||
const mode = DatabaseSearchModes.find((option) => option.value == value);
|
||||
|
||||
return mode?.value || "every_word";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DatabaseSearchMode } from "@/src/types";
|
||||
import { SettingsDatabasePath } from "@/src/dict/settings-pages-dict";
|
||||
|
||||
type Params = {
|
||||
table?: string;
|
||||
search?: string;
|
||||
mode?: DatabaseSearchMode;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The database browser's address for what it is showing, so a table, a
|
||||
* search and a page can be linked to and the browser's own back button
|
||||
* walks back through them. What is at its default is left out.
|
||||
*/
|
||||
export default function databaseHref({ table, search, mode, page }: Params): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (table) params.set("table", table);
|
||||
if (search) params.set("search", search);
|
||||
if (mode && mode != "every_word") params.set("mode", mode);
|
||||
if (page && page > 1) params.set("page", String(page));
|
||||
|
||||
const query = params.toString();
|
||||
|
||||
return query ? `${SettingsDatabasePath}?${query}` : SettingsDatabasePath;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabDatabaseTables from "@/src/functions/backend/database/grab-database-tables";
|
||||
import grabTableRecords from "@/src/functions/backend/database/grab-table-records";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
|
||||
|
||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||
|
||||
/**
|
||||
* The database browser's page data: every table with its size and record
|
||||
* count, and the first page of the table asked for. Super admins only;
|
||||
* everyone else lands on the general settings.
|
||||
*/
|
||||
export default async function grabDatabaseServerProps(params: Params) {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props) return settings;
|
||||
|
||||
if (!(await grabSuperAdminUser({ req: params.req }))) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: SettingsDefaultPath,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tables = await grabDatabaseTables().catch(() => []);
|
||||
|
||||
if (!tables.length) {
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
database_tables: [],
|
||||
database_records: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const asked = params.query?.table;
|
||||
const first = tables[0];
|
||||
const table = typeof asked == "string" && asked ? asked : first?.name;
|
||||
const records = await grabTableRecords({ table }).catch(() => null);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
database_tables: tables,
|
||||
database_records: records,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import type { DatabaseRecordsPayload, DatabaseTableSummary } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
table: DatabaseTableSummary;
|
||||
/**
|
||||
* The page of records under the line, for the matched count of a
|
||||
* search.
|
||||
*/
|
||||
records: DatabaseRecordsPayload | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the open table is, above its records: how many it holds, how many
|
||||
* a search matched, what it takes on disk and how wide it is.
|
||||
*/
|
||||
export default function tableSummaryText({ table, records }: Params): string {
|
||||
const rows = table.rows === null ? "-" : table.rows.toLocaleString();
|
||||
const size = table.size_bytes === null ? "" : `, ${formatBytes({ bytes: table.size_bytes })} on disk`;
|
||||
const matched = records?.search ? `, ${records.total.toLocaleString()} matching` : "";
|
||||
const columns = `${table.columns.length} column${table.columns.length == 1 ? "" : "s"}`;
|
||||
|
||||
return `${rows} record${table.rows == 1 ? "" : "s"}${matched}${size} - ${columns}`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DatabaseRecordPayload } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The table and key of the record the modal is showing; nothing while
|
||||
* no record is open.
|
||||
*/
|
||||
table?: string;
|
||||
key?: string | number;
|
||||
/**
|
||||
* Whether the modal is open at all, so nothing loads for a closed one.
|
||||
*/
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One record, in full, for the modal. Loaded when the modal opens (the
|
||||
* page's cells are cut short), and loaded again whenever another record is
|
||||
* opened, so a click is answered with the whole value.
|
||||
*/
|
||||
export default function useDatabaseRecord({ table, key, open }: Params) {
|
||||
const [record, set_record] = useState<DatabaseRecordPayload | null>(null);
|
||||
const [loading, set_loading] = useState(false);
|
||||
const [error, set_error] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !table || key === undefined || key === null) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const load = async () => {
|
||||
set_loading(true);
|
||||
set_error(undefined);
|
||||
set_record(null);
|
||||
|
||||
const query = new URLSearchParams({
|
||||
table,
|
||||
key: String(key),
|
||||
});
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<DatabaseRecordPayload>>(
|
||||
`/api/admin/database/record?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
set_loading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
set_error(res?.msg || `Couldn't read that record.`);
|
||||
return;
|
||||
}
|
||||
|
||||
set_record(res.singleRes);
|
||||
};
|
||||
|
||||
load().catch(() => {});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [table, key, open]);
|
||||
|
||||
return { record, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DatabaseRecordsPayload, DatabaseSearchMode } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
table: string;
|
||||
search: string;
|
||||
mode: DatabaseSearchMode;
|
||||
page: number;
|
||||
/**
|
||||
* The records the page already carries, when this is exactly what it
|
||||
* is asking for.
|
||||
*/
|
||||
page_records?: DatabaseRecordsPayload | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One page of one table's records. The page's own copy is used when it is
|
||||
* what is asked for (no search, first page); anything else loads from the
|
||||
* API, so a page never carries a search it may not need.
|
||||
*/
|
||||
export default function useDatabaseRecords({ table, search, mode, page, page_records }: Params) {
|
||||
const from_page = Boolean(
|
||||
page_records &&
|
||||
page_records.table.name == table &&
|
||||
page_records.page == page &&
|
||||
page_records.search == search &&
|
||||
page_records.mode == mode,
|
||||
);
|
||||
const [records, set_records] = useState<DatabaseRecordsPayload | null>(from_page ? page_records || null : null);
|
||||
const [loading, set_loading] = useState(!from_page);
|
||||
const [error, set_error] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (from_page) {
|
||||
set_records(page_records || null);
|
||||
set_loading(false);
|
||||
set_error(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const load = async () => {
|
||||
set_loading(true);
|
||||
set_error(undefined);
|
||||
|
||||
const query = new URLSearchParams({
|
||||
table,
|
||||
page: String(page),
|
||||
mode,
|
||||
});
|
||||
|
||||
if (search) query.set("search", search);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<DatabaseRecordsPayload>>(
|
||||
`/api/admin/database/records?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
set_loading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
set_records(null);
|
||||
set_error(res?.msg || `Couldn't read those records.`);
|
||||
return;
|
||||
}
|
||||
|
||||
set_records(res.singleRes);
|
||||
};
|
||||
|
||||
load().catch(() => {});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [table, search, mode, page, from_page, page_records]);
|
||||
|
||||
return { records, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { DatabaseCell as DatabaseCellValue } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
cell?: DatabaseCellValue;
|
||||
/**
|
||||
* Whether the cell's record can be opened to read it whole.
|
||||
*/
|
||||
openable: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One value in the records table, cut to three lines so a long column
|
||||
* can't push the rest of the row out of sight. Clicking it opens the
|
||||
* record in full.
|
||||
*/
|
||||
export default function DatabaseCell({ cell, openable, onOpen }: Props) {
|
||||
if (!cell || cell.kind == "null") {
|
||||
return (
|
||||
<td className="px-3 py-2.5 align-top">
|
||||
<Span size="smaller" variant="faded" className="font-mono">
|
||||
NULL
|
||||
</Span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="px-3 py-2.5 align-top max-w-[280px]">
|
||||
<Span
|
||||
size="small"
|
||||
className={twMerge(
|
||||
"block line-clamp-3 whitespace-pre-wrap break-words font-mono",
|
||||
cell.kind == "number" ? "text-ink-secondary" : "text-ink",
|
||||
cell.kind == "blob" && "text-ink-secondary",
|
||||
openable && "cursor-pointer",
|
||||
)}
|
||||
onClick={openable ? onOpen : undefined}
|
||||
title={
|
||||
openable
|
||||
? cell.truncated
|
||||
? "Cut short here: open the record to read the rest"
|
||||
: "Open the record to read it whole"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{cell.text}
|
||||
</Span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
/**
|
||||
* What the column is, its declared type, shown on hover so the head
|
||||
* stays one line of names.
|
||||
*/
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One column name over the records table. Column names are read as code:
|
||||
* the database wrote them, nobody typed them.
|
||||
*/
|
||||
export default function DatabaseHeadCell({ title, hint }: Props) {
|
||||
return (
|
||||
<th className="px-3 py-2.5 text-left font-medium max-w-[280px]">
|
||||
<Span size="smaller" variant="faded" className="block truncate font-mono" title={hint || title}>
|
||||
{title}
|
||||
</Span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { DatabaseRecordPayload, DatabaseTableSummary } from "@/src/types";
|
||||
import DatabaseRecordField from "./database-record-field";
|
||||
|
||||
type Props = {
|
||||
table: DatabaseTableSummary;
|
||||
record: DatabaseRecordPayload | null;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the record modal says before a record lands, and the record itself
|
||||
* once it does: every column's whole value, in the order the table
|
||||
* declares them.
|
||||
*/
|
||||
export default function DatabaseRecordBody({ table, record, loading, error }: Props) {
|
||||
if (error) {
|
||||
return (
|
||||
<Paper>
|
||||
<Center className="p-10">
|
||||
<Span size="small">{error}</Span>
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const full = record?.record;
|
||||
|
||||
if (loading || !full) {
|
||||
return (
|
||||
<Paper>
|
||||
<Center className="p-16">
|
||||
<Loading />
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-0 max-h-[70vh] overflow-y-auto">
|
||||
{table.columns.map((column) => (
|
||||
<DatabaseRecordField key={column.name} column={column} cell={full.cells[column.name]} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { DatabaseCell, DatabaseTableColumn } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
column: DatabaseTableColumn;
|
||||
cell?: DatabaseCell;
|
||||
};
|
||||
|
||||
/**
|
||||
* One column of an open record: its name and declared type, then its whole
|
||||
* value. Text keeps its own line breaks and wraps, so a stored JSON body or
|
||||
* a bot's message reads as it was written.
|
||||
*/
|
||||
export default function DatabaseRecordField({ column, cell }: Props) {
|
||||
const is_null = !cell || cell.kind == "null";
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-1 py-3">
|
||||
<Span size="smaller" variant="faded" className="font-mono">
|
||||
{`${column.name}${column.type ? ` (${column.type})` : ""}`}
|
||||
</Span>
|
||||
<Span
|
||||
size="small"
|
||||
className={twMerge(
|
||||
"block font-mono break-words",
|
||||
is_null ? "text-ink-disabled" : "text-ink whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{is_null ? "NULL" : cell.text}
|
||||
</Span>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { DatabaseTableSummary } from "@/src/types";
|
||||
import useDatabaseRecord from "../(hooks)/use-database-record";
|
||||
import DatabaseRecordBody from "./database-record-body";
|
||||
|
||||
type Props = {
|
||||
table: DatabaseTableSummary;
|
||||
/**
|
||||
* The record being read, by its key; nothing while no record is open.
|
||||
*/
|
||||
record_key: string | number | null;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One record, whole: every column's value as its own block, in the order
|
||||
* the table declares them, so the text a cell cut at three lines can be
|
||||
* read to its end.
|
||||
*/
|
||||
export default function DatabaseRecordModal({ table, record_key, open, setOpen }: Props) {
|
||||
const { record, loading, error } = useDatabaseRecord({
|
||||
table: table.name,
|
||||
key: record_key ?? undefined,
|
||||
open,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Stack className="w-full items-stretch gap-5">
|
||||
<Stack className="gap-1 pr-10">
|
||||
<Row className="gap-2! items-baseline">
|
||||
<H3 className="font-mono">{table.name}</H3>
|
||||
<Span size="smaller" variant="faded" className="font-mono">
|
||||
{record_key === null ? "" : `key ${record_key}`}
|
||||
</Span>
|
||||
</Row>
|
||||
<Span size="smaller" variant="faded">
|
||||
{table.columns.length} column{table.columns.length == 1 ? "" : "s"}, read-only
|
||||
</Span>
|
||||
</Stack>
|
||||
<DatabaseRecordBody table={table} record={record} loading={loading} error={error} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DatabaseRecord } from "@/src/types";
|
||||
import DatabaseCell from "./database-cell";
|
||||
|
||||
type Props = {
|
||||
record: DatabaseRecord;
|
||||
columns: string[];
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One record as a table row: every column's value cut to its cell, none of
|
||||
* them spanning the whole value. A cell is the way into the record, so a
|
||||
* long text can be read to its end.
|
||||
*/
|
||||
export default function DatabaseRecordRow({ record, columns, onOpen }: Props) {
|
||||
return (
|
||||
<tr className="border-t border-slate-200 dark:border-white/10">
|
||||
{columns.map((column) => (
|
||||
<DatabaseCell
|
||||
key={column}
|
||||
cell={record.cells[column]}
|
||||
openable={record.key !== null}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Pagination from "@moduletrace/twui/elements/Pagination";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { useState } from "react";
|
||||
import type { DatabaseRecordsPayload, DatabaseSearchMode, DatabaseTableSummary } from "@/src/types";
|
||||
import tableSummaryText from "../(functions)/table-summary-text";
|
||||
import DatabaseRecordsTable from "./database-records-table";
|
||||
import DatabaseRecordModal from "./database-record-modal";
|
||||
import DatabaseSearchFields from "./database-search-fields";
|
||||
|
||||
type Props = {
|
||||
table: DatabaseTableSummary;
|
||||
records: DatabaseRecordsPayload | null;
|
||||
search: string;
|
||||
mode: DatabaseSearchMode;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onSubmitSearch: (search: string) => void;
|
||||
onChangeMode: (mode: DatabaseSearchMode) => void;
|
||||
onChangePage: (page: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The open table's records: what it holds, the search over it, the page of
|
||||
* records and the pager under them, each record opening whole in a modal.
|
||||
*/
|
||||
export default function DatabaseRecordsPane({
|
||||
table,
|
||||
records,
|
||||
search,
|
||||
mode,
|
||||
loading,
|
||||
error,
|
||||
onSubmitSearch,
|
||||
onChangeMode,
|
||||
onChangePage,
|
||||
}: Props) {
|
||||
const [record_key, set_record_key] = useState<string | number | null>(null);
|
||||
const [modal_open, set_modal_open] = useState(false);
|
||||
|
||||
const open_record = ({ key }: { key: string | number }) => {
|
||||
set_record_key(key);
|
||||
set_modal_open(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper className="w-full xl:p-8 grow">
|
||||
<Stack className="grow min-w-0 w-full items-stretch">
|
||||
<Stack className="gap-1! items-stretch">
|
||||
<H2 className="text-[18px]! leading-6 font-bold tracking-normal font-mono text-ink">{table.name}</H2>
|
||||
<Span size="smaller" variant="faded">
|
||||
{tableSummaryText({ table, records })}
|
||||
</Span>
|
||||
</Stack>
|
||||
<DatabaseSearchFields
|
||||
search={search}
|
||||
mode={mode}
|
||||
loading={loading}
|
||||
onSubmit={onSubmitSearch}
|
||||
onChangeMode={onChangeMode}
|
||||
/>
|
||||
<Stack className="w-full mt-3 items-stretch">
|
||||
<DatabaseRecordsBody
|
||||
table={table}
|
||||
records={records}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onOpenRecord={(key) => open_record({ key })}
|
||||
/>
|
||||
</Stack>
|
||||
{records && records.total > records.page_size ? (
|
||||
<Pagination
|
||||
count={records.total}
|
||||
page={records.page}
|
||||
setPage={(next) => {
|
||||
const page = typeof next == "function" ? next(records.page) : next;
|
||||
onChangePage(page);
|
||||
}}
|
||||
limit={records.page_size}
|
||||
className="mt-4 w-full justify-center"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<DatabaseRecordModal
|
||||
table={table}
|
||||
record_key={record_key}
|
||||
open={modal_open}
|
||||
setOpen={set_modal_open}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
type BodyProps = {
|
||||
table: DatabaseTableSummary;
|
||||
records: DatabaseRecordsPayload | null;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onOpenRecord: (key: string | number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page of records, or what stands in for it: loading, a refusal, or a
|
||||
* table with nothing (or nothing matching) in it.
|
||||
*/
|
||||
function DatabaseRecordsBody({ table, records, loading, error, onOpenRecord }: BodyProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<Paper>
|
||||
<Center className="p-16">
|
||||
<Span size="small">{error}</Span>
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !records) {
|
||||
return (
|
||||
<Paper>
|
||||
<Center className="p-16">
|
||||
<Loading />
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!records.records.length) {
|
||||
return (
|
||||
<Paper>
|
||||
<Center className="p-16">
|
||||
<Stack className="items-center gap-1">
|
||||
<Span size="small">
|
||||
{records.search ? "Nothing in this table matches." : "This table is empty."}
|
||||
</Span>
|
||||
<Span size="smaller" variant="faded">
|
||||
{records.search ? "Clear the search to see its records." : table.name}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return <DatabaseRecordsTable records={records} onOpenRecord={onOpenRecord} />;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { DatabaseRecordsPayload, DatabaseTableSummary } from "@/src/types";
|
||||
import DatabaseRecordsTable from "./database-records-table";
|
||||
|
||||
const table: DatabaseTableSummary = {
|
||||
name: "bot_messages",
|
||||
type: "table",
|
||||
rows: 295,
|
||||
size_bytes: 123456,
|
||||
columns: [
|
||||
{ name: "id", type: "INTEGER", pk: true },
|
||||
{ name: "text", type: "TEXT", pk: false },
|
||||
{ name: "meta", type: "TEXT", pk: false },
|
||||
],
|
||||
key: { kind: "column", name: "id" },
|
||||
};
|
||||
|
||||
const records: DatabaseRecordsPayload = {
|
||||
table,
|
||||
records: [
|
||||
{
|
||||
key: 295,
|
||||
cells: {
|
||||
id: { text: "295", kind: "number", truncated: false },
|
||||
text: {
|
||||
text: "line one\nline two\nline three\nline four",
|
||||
kind: "text",
|
||||
truncated: true,
|
||||
},
|
||||
meta: { text: "", kind: "null", truncated: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
search: "",
|
||||
mode: "every_word",
|
||||
};
|
||||
|
||||
describe("DatabaseRecordsTable", () => {
|
||||
test("draws every column of the table", () => {
|
||||
const html = renderToString(<DatabaseRecordsTable records={records} onOpenRecord={() => {}} />);
|
||||
|
||||
expect(html).toContain(">id<");
|
||||
expect(html).toContain(">text<");
|
||||
expect(html).toContain(">meta<");
|
||||
expect(html).toContain("295");
|
||||
});
|
||||
|
||||
test("cuts a long value to three lines rather than letting it run", () => {
|
||||
const html = renderToString(<DatabaseRecordsTable records={records} onOpenRecord={() => {}} />);
|
||||
|
||||
expect(html).toContain("line-clamp-3");
|
||||
expect(html).toContain("line one");
|
||||
});
|
||||
|
||||
test("says NULL instead of leaving a cell blank", () => {
|
||||
const html = renderToString(<DatabaseRecordsTable records={records} onOpenRecord={() => {}} />);
|
||||
|
||||
expect(html).toContain("NULL");
|
||||
});
|
||||
|
||||
test("a table with no key still reads, without a way into a record", () => {
|
||||
const html = renderToString(
|
||||
<DatabaseRecordsTable
|
||||
records={{
|
||||
...records,
|
||||
table: { ...table, key: { kind: "none" } },
|
||||
records: [{ key: null, cells: records.records[0]?.cells || {} }],
|
||||
}}
|
||||
onOpenRecord={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("line one");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import type { DatabaseRecordsPayload } from "@/src/types";
|
||||
import DatabaseHeadCell from "./database-head-cell";
|
||||
import DatabaseRecordRow from "./database-record-row";
|
||||
|
||||
type Props = {
|
||||
records: DatabaseRecordsPayload;
|
||||
onOpenRecord: (key: string | number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One page of a table's records: every column in its declared order, with
|
||||
* its declared type on hover. A record with a key opens in full when any of
|
||||
* its cells is clicked; a table with no key at all (a virtual table with no
|
||||
* primary key) reads as plain cells, since nothing can open a record it
|
||||
* can't name.
|
||||
*/
|
||||
export default function DatabaseRecordsTable({ records, onOpenRecord }: Props) {
|
||||
const { table } = records;
|
||||
|
||||
return (
|
||||
<Paper className="p-0 gap-0 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
{table.columns.map((column) => (
|
||||
<DatabaseHeadCell
|
||||
key={column.name}
|
||||
title={column.name}
|
||||
hint={column.type || undefined}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.records.map((record, index) => (
|
||||
<DatabaseRecordRow
|
||||
key={
|
||||
record.key === null
|
||||
? `row-${index}`
|
||||
: String(record.key)
|
||||
}
|
||||
record={record}
|
||||
columns={table.columns.map((column) => column.name)}
|
||||
onOpen={() => {
|
||||
if (record.key !== null)
|
||||
onOpenRecord(record.key);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DatabaseSearchMode } from "@/src/types";
|
||||
import { DatabaseSearchModes } from "../(data)/database-search-modes";
|
||||
|
||||
type Props = {
|
||||
search: string;
|
||||
mode: DatabaseSearchMode;
|
||||
loading?: boolean;
|
||||
onSubmit: (search: string) => void;
|
||||
onChangeMode: (mode: DatabaseSearchMode) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrowing the open table down: a search over every column's text and how
|
||||
* it matches. Enter (or the clear button, which runs it empty) is what asks
|
||||
* for it, so typing doesn't fire a query a keystroke.
|
||||
*/
|
||||
export default function DatabaseSearchFields({ search, mode, loading, onSubmit, onChangeMode }: Props) {
|
||||
const [value, set_value] = useState(search);
|
||||
|
||||
// The search the browser is showing can change without this input: the
|
||||
// browser's back button, or a link that carries its own.
|
||||
useEffect(() => set_value(search), [search]);
|
||||
|
||||
return (
|
||||
<Row className="w-full mt-3 gap-2! flex-wrap items-start md:flex-nowrap">
|
||||
<Input
|
||||
placeholder="Search every column of this table"
|
||||
value={value}
|
||||
disabled={loading}
|
||||
onChange={(event) => set_value(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key == "Enter") onSubmit(event.currentTarget.value);
|
||||
}}
|
||||
clearInputProps={{
|
||||
onClick: () => {
|
||||
set_value("");
|
||||
onSubmit("");
|
||||
},
|
||||
}}
|
||||
wrapperWrapperProps={{ className: "w-full grow" }}
|
||||
/>
|
||||
<Select
|
||||
title="How the search matches"
|
||||
aria-label="How the search matches"
|
||||
options={DatabaseSearchModes.map((option) => ({
|
||||
title: option.title,
|
||||
value: option.value,
|
||||
default: option.value == mode,
|
||||
}))}
|
||||
changeHandler={(next) => onChangeMode(next)}
|
||||
wrapperWrapperProps={{ className: "w-[150px] shrink-0" }}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { DatabaseTableSummary } from "@/src/types";
|
||||
import DatabaseTableRow from "./database-table-row";
|
||||
|
||||
type Props = {
|
||||
tables: DatabaseTableSummary[];
|
||||
/**
|
||||
* The table the browser is showing.
|
||||
*/
|
||||
table: string;
|
||||
onOpenTable: (table: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every table of the database, in one list beside the open one's records.
|
||||
* The record counts are the browser's first read, so the list already
|
||||
* says where the data sits before anything is clicked.
|
||||
*/
|
||||
export default function DatabaseTableList({ tables, table, onOpenTable }: Props) {
|
||||
return (
|
||||
<Stack className="w-full lg:w-[240px] shrink-0 items-stretch">
|
||||
<H2 className="mb-2 text-[18px]! leading-6 font-bold tracking-normal text-ink">Tables</H2>
|
||||
<Span className="mb-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{tables.length} table{tables.length == 1 ? "" : "s"}
|
||||
</Span>
|
||||
<Divider />
|
||||
<Stack className="gap-0! items-stretch">
|
||||
{tables.map((entry) => (
|
||||
<DatabaseTableRow
|
||||
key={entry.name}
|
||||
table={entry}
|
||||
selected={entry.name == table}
|
||||
onOpen={() => onOpenTable(entry.name)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { DatabaseTableSummary } from "@/src/types";
|
||||
import DatabaseTableRow from "./database-table-row";
|
||||
|
||||
const table: DatabaseTableSummary = {
|
||||
name: "bot_file_vectors",
|
||||
type: "virtual",
|
||||
rows: 12,
|
||||
size_bytes: 2048,
|
||||
columns: [],
|
||||
key: { kind: "none" },
|
||||
};
|
||||
|
||||
describe("DatabaseTableRow", () => {
|
||||
test("names the table, its record count and its size", () => {
|
||||
const html = renderToString(<DatabaseTableRow table={table} selected={false} onOpen={() => {}} />);
|
||||
|
||||
expect(html).toContain("bot_file_vectors");
|
||||
expect(html).toContain("12");
|
||||
expect(html).toContain("2.0 KB");
|
||||
});
|
||||
|
||||
test("links to the table's own address", () => {
|
||||
const html = renderToString(<DatabaseTableRow table={table} selected={false} onOpen={() => {}} />);
|
||||
|
||||
expect(html).toContain(`href="/admin/settings/database?table=bot_file_vectors"`);
|
||||
});
|
||||
|
||||
test("a table with no count yet shows a dash", () => {
|
||||
const html = renderToString(
|
||||
<DatabaseTableRow
|
||||
table={{ ...table, rows: null, size_bytes: null }}
|
||||
selected={false}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("-");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Layers, Table2 } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import type { DatabaseTableSummary } from "@/src/types";
|
||||
import databaseHref from "../(functions)/database-href";
|
||||
|
||||
type Props = {
|
||||
table: DatabaseTableSummary;
|
||||
selected: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One table in the database browser's picker: its name, what it holds and
|
||||
* what it takes on disk, with a virtual table's icon telling it apart from
|
||||
* the ordinary ones.
|
||||
*/
|
||||
export default function DatabaseTableRow({ table, selected, onOpen }: Props) {
|
||||
const Icon = table.type == "virtual" ? Layers : Table2;
|
||||
const rows = table.rows === null ? "-" : table.rows.toLocaleString();
|
||||
const size = table.size_bytes === null ? null : formatBytes({ bytes: table.size_bytes });
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={databaseHref({ table: table.name })}
|
||||
title={`${table.name}: ${rows} record${table.rows == 1 ? "" : "s"}${size ? `, ${size}` : ""}`}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}}
|
||||
className={twMerge(
|
||||
"flex w-full min-w-0 items-center gap-2 rounded-default px-2 py-1.5 border-0 text-ink",
|
||||
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
|
||||
selected && "bg-ink/5 hover:bg-ink/5",
|
||||
)}
|
||||
>
|
||||
<Span
|
||||
title={table.type == "virtual" ? "Virtual table" : "Table"}
|
||||
className="size-6 shrink-0 flex items-center justify-center rounded-default-sm bg-sunken text-ink-secondary"
|
||||
>
|
||||
<Icon size={14} aria-hidden />
|
||||
</Span>
|
||||
<Span className="min-w-0 flex-1 truncate font-mono text-[13px] leading-5">{table.name}</Span>
|
||||
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">{rows}</Span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { DatabaseSearchMode } from "@/src/types";
|
||||
import databaseHref from "../(functions)/database-href";
|
||||
import useDatabaseRecords from "../(hooks)/use-database-records";
|
||||
import DatabaseRecordsPane from "../(partials)/database-records-pane";
|
||||
import DatabaseTableList from "../(partials)/database-table-list";
|
||||
|
||||
/**
|
||||
* The database browser's state: which table is open, what is searched in
|
||||
* it and which page of its records, kept in the address so a table or a
|
||||
* search can be linked to and the browser's own back button walks back
|
||||
* through them. The open table's first page comes with the page; anything
|
||||
* else loads through the API.
|
||||
*/
|
||||
export default function DatabaseBrowser() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const tables = pageProps?.database_tables || [];
|
||||
const page_records = pageProps?.database_records;
|
||||
const [table, set_table] = useState(page_records?.table.name || tables[0]?.name || "");
|
||||
const [search, set_search] = useState("");
|
||||
const [mode, set_mode] = useState<DatabaseSearchMode>("every_word");
|
||||
const [page, set_page] = useState(1);
|
||||
const { records, loading, error } = useDatabaseRecords({
|
||||
table,
|
||||
search,
|
||||
mode,
|
||||
page,
|
||||
page_records,
|
||||
});
|
||||
const open = records?.table || tables.find((entry) => entry.name == table) || null;
|
||||
|
||||
const navigate = ({
|
||||
next_table,
|
||||
next_search,
|
||||
next_mode,
|
||||
next_page,
|
||||
}: {
|
||||
next_table: string;
|
||||
next_search: string;
|
||||
next_mode: DatabaseSearchMode;
|
||||
next_page: number;
|
||||
}) => {
|
||||
set_table(next_table);
|
||||
set_search(next_search);
|
||||
set_mode(next_mode);
|
||||
set_page(next_page);
|
||||
|
||||
const url = new URL(databaseHref({
|
||||
table: next_table,
|
||||
search: next_search,
|
||||
mode: next_mode,
|
||||
page: next_page,
|
||||
}), window.location.origin);
|
||||
|
||||
history.pushState(null, "", url);
|
||||
};
|
||||
|
||||
if (!tables.length) {
|
||||
return (
|
||||
<Center className="p-16">
|
||||
<Span size="small">
|
||||
The database has no tables yet, or they couldn't be read.
|
||||
</Span>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="w-full mt-6 gap-6! flex-wrap items-start lg:flex-nowrap">
|
||||
<DatabaseTableList
|
||||
tables={tables}
|
||||
table={table}
|
||||
onOpenTable={(next_table) =>
|
||||
navigate({ next_table, next_search: "", next_mode: mode, next_page: 1 })
|
||||
}
|
||||
/>
|
||||
{open ? (
|
||||
<DatabaseRecordsPane
|
||||
table={open}
|
||||
records={records}
|
||||
search={search}
|
||||
mode={mode}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSubmitSearch={(next_search) =>
|
||||
navigate({ next_table: table, next_search, next_mode: mode, next_page: 1 })
|
||||
}
|
||||
onChangeMode={(next_mode) =>
|
||||
navigate({ next_table: table, next_search: search, next_mode, next_page: 1 })
|
||||
}
|
||||
onChangePage={(next_page) =>
|
||||
navigate({ next_table: table, next_search: search, next_mode: mode, next_page })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack className="grow items-stretch">
|
||||
<Center className="p-16">
|
||||
<Span size="small">Pick a table to read its records.</Span>
|
||||
</Center>
|
||||
</Stack>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabDatabaseServerProps from "./(functions)/grab-database-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
return await grabDatabaseServerProps(params);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
import DatabaseBrowser from "./(sections)/database-browser";
|
||||
|
||||
export default function AdminSettingsDatabasePage() {
|
||||
return (
|
||||
<SettingsShell
|
||||
title="Database"
|
||||
description="Every table and every record, read-only: open a record to read a value whole, search a table across all of its columns."
|
||||
wide
|
||||
>
|
||||
<DatabaseBrowser />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Database" });
|
||||
Reference in New Issue
Block a user