Files
Genarrative/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx

315 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {Eye, RefreshCcw, Search, X} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type {
AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminDatabaseTablesPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
export function AdminDatabaseTablesPage({
token,
onUnauthorized,
}: AdminDatabaseTablesPageProps) {
const [tables, setTables] = useState<string[]>([]);
const [tableName, setTableName] = useState(() => readHashTableName());
const [search, setSearch] = useState('');
const [filters, setFilters] = useState('');
const [limit, setLimit] = useState('100');
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(null);
const [detailRow, setDetailRow] = useState<AdminDatabaseTableRowPayload | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingRows, setIsLoadingRows] = useState(false);
useEffect(() => {
void loadTables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
const nextTableName = readHashTableName();
if (nextTableName) {
setTableName(nextTableName);
}
const handleHashChange = () => {
const tableFromHash = readHashTableName();
if (tableFromHash) {
setTableName(tableFromHash);
void refreshRows(tableFromHash);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (tables.length && !tableName) {
setTableName(tables[0] ?? '');
}
}, [tableName, tables]);
useEffect(() => {
if (tableName) {
void refreshRows(tableName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName]);
const visibleColumns = useMemo(() => {
const columns = result?.columns ?? [];
if (columns.length) {
return columns;
}
const firstRow = result?.rows[0];
return firstRow ? Object.keys(firstRow.cells) : [];
}, [result]);
async function loadTables() {
setIsLoadingTables(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTables(token);
setTables(response.tables);
if (response.fetchErrors.length) {
setErrorMessage(response.fetchErrors.join(''));
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingTables(false);
}
}
async function refreshRows(nextTableName = tableName) {
const normalizedTableName = nextTableName.trim();
if (!normalizedTableName) {
return;
}
setIsLoadingRows(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTableRows(token, normalizedTableName, {
search,
filters,
limit: parseLimit(limit),
});
setResult(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingRows(false);
}
}
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void refreshRows();
}
function handleTableChange(nextTableName: string) {
setTableName(nextTableName);
const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`;
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p>SpacetimeDB </p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoadingTables}
type="button"
onClick={() => void loadTables()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoadingTables ? '刷新中' : '刷新表'}</span>
</button>
<button
className="admin-primary-button"
disabled={!tableName || isLoadingRows}
type="button"
onClick={() => void refreshRows()}
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
</div>
</div>
<form className="admin-panel admin-form" onSubmit={handleSearch}>
<div className="admin-table-query-grid">
<label className="admin-field">
<span></span>
<select
value={tableName}
onChange={(event) => handleTableChange(event.target.value)}
>
{tableName && !tables.includes(tableName) ? (
<option value={tableName}>{tableName}</option>
) : null}
{tables.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<input
placeholder="全部"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label className="admin-field">
<span> JSON</span>
<input
placeholder='{"user_id":"u1"}'
value={filters}
onChange={(event) => setFilters(event.target.value)}
/>
</label>
<label className="admin-field admin-field-compact">
<span></span>
<input
inputMode="numeric"
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<button className="admin-secondary-button" disabled={isLoadingRows} type="submit">
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
</div>
</form>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>{result?.tableName || tableName || '数据行'}</h3>
<span>{result?.totalReturned ?? 0} </span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
{visibleColumns.map((column) => (
<th key={column}>{column}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{result?.rows.length ? (
result.rows.map((row, rowIndex) => (
<tr
key={buildRowKey(row, rowIndex)}
data-clickable="true"
onClick={() => setDetailRow(row)}
>
{visibleColumns.map((column) => (
<td key={column}>{formatCellValue(row.cells[column])}</td>
))}
<td>
<button
className="admin-secondary-button"
type="button"
onClick={(event) => {
event.stopPropagation();
setDetailRow(row);
}}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={Math.max(visibleColumns.length + 1, 1)}></td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{detailRow ? (
<div className="admin-confirm-backdrop" role="presentation">
<section className="admin-detail-panel" role="dialog" aria-modal="true">
<div className="admin-panel-heading">
<h3></h3>
<button
className="admin-ghost-button"
title="关闭"
type="button"
onClick={() => setDetailRow(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<pre className="admin-code-block">
{JSON.stringify(detailRow.cells, null, 2)}
</pre>
</section>
</div>
) : null}
</section>
);
}
function readHashTableName() {
const hash = window.location.hash;
const queryIndex = hash.indexOf('?');
if (queryIndex < 0) {
return '';
}
return new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? '';
}
function parseLimit(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) ? parsed : 100;
}
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
const firstValue = Object.values(row.cells)[0];
return `${rowIndex}-${String(firstValue ?? '')}`;
}
function formatCellValue(value: unknown) {
if (value === null || typeof value === 'undefined' || value === '') {
return '-';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return <pre className="admin-json-preview">{JSON.stringify(value, null, 2)}</pre>;
}