202 lines
6.3 KiB
TypeScript
202 lines
6.3 KiB
TypeScript
|
|
import { IconDatabase } from "@tabler/icons-react";
|
||
|
|
import { useState } from "react";
|
||
|
|
import { Badge, Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap";
|
||
|
|
import CodeEditor from "@uiw/react-textarea-code-editor";
|
||
|
|
import { useQuery } from "@tanstack/react-query";
|
||
|
|
import { executeQuery, getTables, getTableData } from "src/api/backend";
|
||
|
|
import { HasPermission, Loading } from "src/components";
|
||
|
|
import { showError, showSuccess } from "src/notifications";
|
||
|
|
import { ADMIN, VIEW } from "src/modules/Permissions";
|
||
|
|
|
||
|
|
export default function DatabaseManager() {
|
||
|
|
const [activeTab, setActiveTab] = useState<"tables" | "query">("tables");
|
||
|
|
const [activeTable, setActiveTable] = useState<string | null>(null);
|
||
|
|
const [query, setQuery] = useState("SELECT * FROM user LIMIT 10;");
|
||
|
|
const [queryResult, setQueryResult] = useState<any>(null);
|
||
|
|
const [queryLoading, setQueryLoading] = useState(false);
|
||
|
|
|
||
|
|
const { data: tables, isLoading: tablesLoading } = useQuery({
|
||
|
|
queryKey: ["database-tables"],
|
||
|
|
queryFn: getTables,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { data: tableData, isLoading: tableDataLoading } = useQuery({
|
||
|
|
queryKey: ["database-table", activeTable, 0, 50],
|
||
|
|
queryFn: () => getTableData(activeTable as string, 0, 50),
|
||
|
|
enabled: !!activeTable && activeTab === "tables",
|
||
|
|
});
|
||
|
|
|
||
|
|
// Select the first table by default when tables are loaded
|
||
|
|
if (tables && tables.length > 0 && !activeTable) {
|
||
|
|
setActiveTable(tables[0]);
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleExecuteQuery = async () => {
|
||
|
|
try {
|
||
|
|
setQueryLoading(true);
|
||
|
|
const res = await executeQuery(query);
|
||
|
|
setQueryResult(res.result);
|
||
|
|
showSuccess("Query executed successfully");
|
||
|
|
} catch (err: any) {
|
||
|
|
showError(err.message || "Failed to execute query");
|
||
|
|
} finally {
|
||
|
|
setQueryLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderTableData = (data: any[], schema?: any[]) => {
|
||
|
|
if (!data || data.length === 0) return <div className="text-muted p-3">No data</div>;
|
||
|
|
|
||
|
|
const columns = schema ? schema.map((s: any) => s.name || s.Field) : Object.keys(data[0]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="table-responsive">
|
||
|
|
<Table striped bordered hover size="sm" className="mb-0 text-nowrap">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
{columns.map((col: string) => (
|
||
|
|
<th key={col}>{col}</th>
|
||
|
|
))}
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{data.map((row: any, i: number) => (
|
||
|
|
<tr key={i}>
|
||
|
|
{columns.map((col: string) => (
|
||
|
|
<td key={`${i}-${col}`}>
|
||
|
|
{row[col] === null ? (
|
||
|
|
<span className="text-muted">NULL</span>
|
||
|
|
) : typeof row[col] === "object" ? (
|
||
|
|
JSON.stringify(row[col])
|
||
|
|
) : (
|
||
|
|
String(row[col])
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
))}
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<HasPermission section={ADMIN} permission={VIEW}>
|
||
|
|
<Container className="my-4" fluid>
|
||
|
|
<div className="d-flex align-items-center mb-4">
|
||
|
|
<IconDatabase size={28} className="me-2 text-primary" />
|
||
|
|
<h2 className="mb-0">Database Manager</h2>
|
||
|
|
<Badge bg="danger" className="ms-3 rounded-pill text-uppercase" style={{ letterSpacing: "1px" }}>
|
||
|
|
Super Admin Only
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Nav variant="pills" className="mb-4">
|
||
|
|
<Nav.Item>
|
||
|
|
<Nav.Link
|
||
|
|
active={activeTab === "tables"}
|
||
|
|
onClick={() => setActiveTab("tables")}
|
||
|
|
className="rounded-pill px-4"
|
||
|
|
style={{ cursor: "pointer" }}
|
||
|
|
>
|
||
|
|
Data Viewer
|
||
|
|
</Nav.Link>
|
||
|
|
</Nav.Item>
|
||
|
|
<Nav.Item>
|
||
|
|
<Nav.Link
|
||
|
|
active={activeTab === "query"}
|
||
|
|
onClick={() => setActiveTab("query")}
|
||
|
|
className="rounded-pill px-4"
|
||
|
|
style={{ cursor: "pointer" }}
|
||
|
|
>
|
||
|
|
SQL Editor
|
||
|
|
</Nav.Link>
|
||
|
|
</Nav.Item>
|
||
|
|
</Nav>
|
||
|
|
|
||
|
|
{activeTab === "tables" && (
|
||
|
|
<Row>
|
||
|
|
<Col md={3}>
|
||
|
|
<Card className="shadow-sm">
|
||
|
|
<Card.Header className="bg-light fw-bold">Tables</Card.Header>
|
||
|
|
<div className="list-group list-group-flush" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||
|
|
{tablesLoading && <div className="p-3"><Loading /></div>}
|
||
|
|
{tables?.map((table: string) => (
|
||
|
|
<button
|
||
|
|
key={table}
|
||
|
|
className={`list-group-item list-group-item-action ${activeTable === table ? "active" : ""}`}
|
||
|
|
onClick={() => setActiveTable(table)}
|
||
|
|
>
|
||
|
|
{table}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
</Col>
|
||
|
|
<Col md={9}>
|
||
|
|
<Card className="shadow-sm">
|
||
|
|
<Card.Header className="bg-light d-flex justify-content-between align-items-center">
|
||
|
|
<h5 className="mb-0 fw-bold">{activeTable || "Select a table"}</h5>
|
||
|
|
{tableData && <Badge bg="secondary">{tableData.total} rows</Badge>}
|
||
|
|
</Card.Header>
|
||
|
|
<Card.Body className="p-0" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||
|
|
{tableDataLoading ? (
|
||
|
|
<div className="p-5 d-flex justify-content-center"><Loading /></div>
|
||
|
|
) : (
|
||
|
|
tableData && renderTableData(tableData.rows, tableData.schema)
|
||
|
|
)}
|
||
|
|
</Card.Body>
|
||
|
|
</Card>
|
||
|
|
</Col>
|
||
|
|
</Row>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{activeTab === "query" && (
|
||
|
|
<Card className="shadow-sm">
|
||
|
|
<Card.Header className="bg-light fw-bold">Execute SQL Query</Card.Header>
|
||
|
|
<Card.Body>
|
||
|
|
<div className="mb-3 border rounded">
|
||
|
|
<CodeEditor
|
||
|
|
value={query}
|
||
|
|
language="sql"
|
||
|
|
placeholder="Please enter SQL code."
|
||
|
|
onChange={(ev) => setQuery(ev.target.value)}
|
||
|
|
padding={15}
|
||
|
|
style={{
|
||
|
|
fontSize: 14,
|
||
|
|
backgroundColor: "#f8f9fa",
|
||
|
|
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||
|
|
minHeight: "200px"
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="d-flex justify-content-end mb-4">
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
onClick={handleExecuteQuery}
|
||
|
|
disabled={queryLoading || !query.trim()}
|
||
|
|
>
|
||
|
|
{queryLoading ? <span className="me-2"><Loading /></span> : null}
|
||
|
|
Execute Query
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{queryResult && (
|
||
|
|
<div className="mt-4">
|
||
|
|
<h5 className="mb-3 border-bottom pb-2">Results</h5>
|
||
|
|
{Array.isArray(queryResult) ? renderTableData(queryResult) : (
|
||
|
|
<pre className="p-3 bg-light rounded border">
|
||
|
|
{JSON.stringify(queryResult, null, 2)}
|
||
|
|
</pre>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Card.Body>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</Container>
|
||
|
|
</HasPermission>
|
||
|
|
);
|
||
|
|
}
|