485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import { FormEvent, useEffect, useState } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { Code2, Database, Play, Plug, RefreshCw, Table2, Unplug } from "lucide-react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { useAccountId } from "@/lib/accountStore";
|
|
import {
|
|
listSqlDatabases,
|
|
listSqlTables,
|
|
querySql,
|
|
} from "@/api/cloudProxyClient";
|
|
import type { CloudProvider } from "@/types/cloud";
|
|
import type {
|
|
CloudResource,
|
|
SqlCredentials,
|
|
SqlDatabase,
|
|
SqlEngine,
|
|
SqlQueryResult,
|
|
SqlTable,
|
|
} from "@/types/resource";
|
|
|
|
const SQL_SERVER_DEFAULT_QUERY = `SELECT
|
|
DB_NAME() AS database_name,
|
|
@@SERVERNAME AS server_name,
|
|
@@VERSION AS version;`;
|
|
const POSTGRES_DEFAULT_QUERY = `SELECT
|
|
current_database() AS database_name,
|
|
current_user AS username,
|
|
version();`;
|
|
const MYSQL_DEFAULT_QUERY = `SELECT
|
|
DATABASE() AS database_name,
|
|
CURRENT_USER() AS username,
|
|
VERSION() AS version;`;
|
|
|
|
interface AzureSqlPanelProps {
|
|
cloud: CloudProvider;
|
|
resource?: CloudResource;
|
|
runtimeReachable: boolean;
|
|
}
|
|
|
|
export function AzureSqlPanel({
|
|
cloud,
|
|
resource,
|
|
runtimeReachable,
|
|
}: AzureSqlPanelProps) {
|
|
const accountId = useAccountId();
|
|
const serverId = resource?.id;
|
|
const isAwsRds = cloud === "aws" && resource?.type === "db-instance";
|
|
const engine: SqlEngine = isAwsRds
|
|
? resource?.engine === "mysql" ? "mysql" : "postgresql"
|
|
: resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql";
|
|
const defaultDatabase = engine === "postgresql" ? "postgres" : engine === "mysql" ? "mysql" : "master";
|
|
const defaultQuery =
|
|
engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : engine === "mysql" ? MYSQL_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY;
|
|
const administratorLogin = metadataString(
|
|
resource?.metadata.administratorLogin,
|
|
);
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [connected, setConnected] = useState(false);
|
|
const [databases, setDatabases] = useState<SqlDatabase[]>([]);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const selectedDatabase = searchParams.get("database") || defaultDatabase;
|
|
const tableSchema = searchParams.get("schema");
|
|
const tableName = searchParams.get("table");
|
|
const [queryText, setQueryText] = useState(defaultQuery);
|
|
|
|
const credentials: SqlCredentials = { username, password };
|
|
const databaseExists = databases.some((database) => database.name === selectedDatabase);
|
|
|
|
const tablesQuery = useQuery({
|
|
queryKey: ['sql-tables', accountId, cloud, serverId, engine, selectedDatabase, username],
|
|
queryFn: ({signal}) => listSqlTables(cloud, serverId ?? "", engine, selectedDatabase, credentials, signal),
|
|
enabled: connected && databaseExists && runtimeReachable,
|
|
gcTime: 0,
|
|
});
|
|
const tables = connected && databaseExists ? tablesQuery.data ?? [] : [];
|
|
const selectedTable = tables.find((table) => table.schema === tableSchema && table.name === tableName);
|
|
const previewQuery = selectedTable && tablePreviewQuery(selectedTable, engine);
|
|
|
|
const databasesMut = useMutation({
|
|
mutationFn: () =>
|
|
listSqlDatabases(cloud, serverId ?? "", engine, credentials),
|
|
});
|
|
|
|
const queryMut = useMutation({
|
|
mutationFn: ({ database, query }: { database: string; query: string }) =>
|
|
querySql(cloud, serverId ?? "", engine, database, credentials, query),
|
|
});
|
|
const {mutate: executeQuery, reset: resetQuery} = queryMut;
|
|
|
|
useEffect(() => {
|
|
resetQuery();
|
|
if (!connected || !previewQuery) {
|
|
setQueryText(defaultQuery);
|
|
return;
|
|
}
|
|
setQueryText(previewQuery);
|
|
executeQuery({database: selectedDatabase, query: previewQuery});
|
|
}, [connected, selectedDatabase, previewQuery, defaultQuery, executeQuery, resetQuery]);
|
|
|
|
useEffect(() => {
|
|
setUsername(administratorLogin);
|
|
setPassword("");
|
|
setConnected(false);
|
|
setDatabases([]);
|
|
setQueryText(defaultQuery);
|
|
}, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]);
|
|
|
|
if (cloud !== "azure" && !isAwsRds) return null;
|
|
|
|
if (!serverId) {
|
|
return (
|
|
<section className="sql-panel">
|
|
<div className="empty compact">
|
|
<h3>Select an Azure database server</h3>
|
|
<p>Databases, tables, rows, and SQL queries load after connection.</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function connect(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!isAwsRds && (!username || !password)) return;
|
|
queryMut.reset();
|
|
loadDatabases();
|
|
}
|
|
|
|
function loadDatabases() {
|
|
databasesMut.mutate(undefined, {
|
|
onSuccess: (items) => {
|
|
setDatabases(items);
|
|
setConnected(true);
|
|
if (!searchParams.has('database')) {
|
|
const database = items.find((item) => item.name === defaultDatabase) ?? items[0];
|
|
if (database) selectDatabase(database);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function disconnect() {
|
|
setConnected(false);
|
|
setPassword("");
|
|
setDatabases([]);
|
|
databasesMut.reset();
|
|
queryMut.reset();
|
|
}
|
|
|
|
function selectDatabase(database: SqlDatabase) {
|
|
setSearchParams((previous) => {
|
|
const next = new URLSearchParams(previous);
|
|
next.set('database', database.name);
|
|
next.delete('schema');
|
|
next.delete('table');
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function previewTable(table: SqlTable) {
|
|
if (table.schema === tableSchema && table.name === tableName) {
|
|
const query = tablePreviewQuery(table, engine);
|
|
setQueryText(query);
|
|
queryMut.mutate({database: selectedDatabase, query});
|
|
return;
|
|
}
|
|
setSearchParams((previous) => {
|
|
const next = new URLSearchParams(previous);
|
|
next.set('database', selectedDatabase);
|
|
next.set('schema', table.schema);
|
|
next.set('table', table.name);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function runQuery() {
|
|
if (!queryText.trim()) return;
|
|
queryMut.mutate({ database: selectedDatabase, query: queryText });
|
|
}
|
|
|
|
const connectionError =
|
|
databasesMut.error instanceof Error ? databasesMut.error.message : null;
|
|
|
|
return (
|
|
<section className="sql-panel">
|
|
<form className="sql-connection-bar" onSubmit={connect}>
|
|
<div className="sql-connection-title">
|
|
<Plug size={16} />
|
|
<span>
|
|
<small>{isAwsRds ? "RDS DATA API" : engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
|
|
<strong>{resource.name}</strong>
|
|
<em>{isAwsRds ? `${resource.engine} · ${resource.status ?? "unknown"}` : metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
|
|
</span>
|
|
</div>
|
|
{!isAwsRds && <label>
|
|
<span>Username</span>
|
|
<input
|
|
className="input"
|
|
value={username}
|
|
onChange={(event) => setUsername(event.target.value)}
|
|
disabled={connected || !runtimeReachable}
|
|
autoComplete="username"
|
|
/>
|
|
</label>}
|
|
{!isAwsRds && <label>
|
|
<span>Password</span>
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
disabled={connected || !runtimeReachable}
|
|
autoComplete="current-password"
|
|
/>
|
|
</label>}
|
|
{connected ? (
|
|
<button className="button" type="button" onClick={disconnect}>
|
|
<Unplug size={14} />
|
|
Disconnect
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="button primary"
|
|
type="submit"
|
|
disabled={
|
|
!runtimeReachable ||
|
|
databasesMut.isPending ||
|
|
(!isAwsRds && (!username || !password))
|
|
}
|
|
>
|
|
<Plug size={14} />
|
|
{databasesMut.isPending ? "Connecting" : "Connect"}
|
|
</button>
|
|
)}
|
|
<small className="sql-credential-note">
|
|
{isAwsRds
|
|
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
|
|
: "Credentials remain in this page only and are sent to the local API per request."}
|
|
</small>
|
|
{connectionError && <div className="form-error">{connectionError}</div>}
|
|
</form>
|
|
|
|
{!connected ? (
|
|
<div className="empty compact sql-connect-empty">
|
|
<h3>Connect to browse SQL data</h3>
|
|
<p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
|
|
</div>
|
|
) : (
|
|
<div className="sql-explorer">
|
|
<div className="sql-column">
|
|
<SqlPanelHeader
|
|
icon={Database}
|
|
eyebrow="Databases"
|
|
title={resource.name}
|
|
detail={`${databases.length} databases`}
|
|
action={
|
|
<button
|
|
className="icon-btn"
|
|
type="button"
|
|
title="Refresh databases"
|
|
disabled={databasesMut.isPending}
|
|
onClick={loadDatabases}
|
|
>
|
|
<RefreshCw size={13} />
|
|
</button>
|
|
}
|
|
/>
|
|
<div className="sql-list">
|
|
{databases.length === 0 && <div className="muted padded">No databases</div>}
|
|
{databases.length > 0 && !databaseExists && <div className="form-error">Database not found: {selectedDatabase}</div>}
|
|
{databases.map((database) => (
|
|
<button
|
|
key={database.name}
|
|
className={`sql-list-row ${selectedDatabase === database.name ? "selected" : ""}`}
|
|
type="button"
|
|
onClick={() => selectDatabase(database)}
|
|
>
|
|
<span>
|
|
<strong>{database.name}</strong>
|
|
<small>{database.state}</small>
|
|
</span>
|
|
{database.isSystem && <em>System</em>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sql-column">
|
|
<SqlPanelHeader
|
|
icon={Table2}
|
|
eyebrow="Tables and views"
|
|
title={selectedDatabase}
|
|
detail={`${tables.length} objects`}
|
|
action={
|
|
<button
|
|
className="icon-btn"
|
|
type="button"
|
|
title="Refresh tables"
|
|
disabled={!databaseExists || tablesQuery.isFetching}
|
|
onClick={() => void tablesQuery.refetch()}
|
|
>
|
|
<RefreshCw size={13} />
|
|
</button>
|
|
}
|
|
/>
|
|
{tablesQuery.error instanceof Error && (
|
|
<div className="form-error">{tablesQuery.error.message}</div>
|
|
)}
|
|
<div className="sql-list">
|
|
{tablesQuery.isLoading && <div className="muted padded">Loading objects</div>}
|
|
{databaseExists && tablesQuery.isSuccess && tables.length === 0 && (
|
|
<div className="muted padded">No user tables or views</div>
|
|
)}
|
|
{databaseExists && tablesQuery.isSuccess && tableName && !selectedTable && <div className="form-error">Table not found: {tableSchema}.{tableName}</div>}
|
|
{tables.map((table) => (
|
|
<button
|
|
key={`${table.schema}.${table.name}`}
|
|
className={`sql-list-row ${selectedTable?.schema === table.schema && selectedTable.name === table.name ? "selected" : ""}`}
|
|
type="button"
|
|
onClick={() => previewTable(table)}
|
|
>
|
|
<span>
|
|
<strong>{table.schema}.{table.name}</strong>
|
|
<small>{table.type}</small>
|
|
</span>
|
|
<em>{table.rowCount === null ? "—" : `${table.rowCount} rows`}</em>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sql-column sql-workspace">
|
|
<SqlPanelHeader
|
|
icon={Code2}
|
|
eyebrow="Query editor"
|
|
title={selectedDatabase}
|
|
detail={engine === "postgresql"
|
|
? "PostgreSQL SQL against selected database"
|
|
: engine === "mysql"
|
|
? "MySQL SQL against selected database"
|
|
: "T-SQL against selected database"}
|
|
/>
|
|
<div className="sql-query-editor">
|
|
<textarea
|
|
className="textarea code-textarea small"
|
|
value={queryText}
|
|
onChange={(event) => setQueryText(event.target.value)}
|
|
spellCheck={false}
|
|
/>
|
|
<button
|
|
className="button primary"
|
|
type="button"
|
|
disabled={!databaseExists || queryMut.isPending || !queryText.trim()}
|
|
onClick={runQuery}
|
|
>
|
|
<Play size={14} />
|
|
{queryMut.isPending ? "Running" : "Run query"}
|
|
</button>
|
|
</div>
|
|
{queryMut.error instanceof Error && (
|
|
<div className="form-error">{queryMut.error.message}</div>
|
|
)}
|
|
<SqlQueryResults result={queryMut.data} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SqlQueryResults({ result }: { result?: SqlQueryResult }) {
|
|
if (!result) {
|
|
return (
|
|
<div className="empty compact sql-results-empty">
|
|
<h3>No query results</h3>
|
|
<p>Select a table for a 100-row preview or run SQL.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const affected = result.rowsAffected.reduce((total, count) => total + count, 0);
|
|
|
|
return (
|
|
<div className="sql-results">
|
|
<div className="sql-results-summary">
|
|
<span>{result.resultSets.length} result sets</span>
|
|
<span>{affected} rows affected</span>
|
|
<span>{result.durationMs} ms</span>
|
|
</div>
|
|
{result.resultSets.length === 0 && (
|
|
<div className="muted padded">Command completed without a result set.</div>
|
|
)}
|
|
{result.resultSets.map((resultSet, resultSetIndex) => (
|
|
<div className="sql-result-set" key={resultSetIndex}>
|
|
<div className="sql-result-set-label">
|
|
Result {resultSetIndex + 1} · {resultSet.rows.length} rows
|
|
{resultSet.truncated && " · first 500 shown"}
|
|
</div>
|
|
<div className="sql-result-table-wrap">
|
|
<table className="table sql-result-table">
|
|
<thead>
|
|
<tr>
|
|
{resultSet.columns.map((column) => (
|
|
<th key={column.name} title={column.type}>
|
|
{column.name}
|
|
<small>{column.type}</small>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{resultSet.rows.map((row, rowIndex) => (
|
|
<tr key={rowIndex}>
|
|
{resultSet.columns.map((column) => {
|
|
const value = formatSqlValue(row[column.name]);
|
|
return (
|
|
<td key={column.name} title={value}>
|
|
{value}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SqlPanelHeader({
|
|
icon: Icon,
|
|
eyebrow,
|
|
title,
|
|
detail,
|
|
action,
|
|
}: {
|
|
icon: typeof Database;
|
|
eyebrow: string;
|
|
title: string;
|
|
detail: string;
|
|
action?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="sql-panel-header">
|
|
<Icon size={15} />
|
|
<span>
|
|
<small>{eyebrow}</small>
|
|
<strong>{title}</strong>
|
|
<em>{detail}</em>
|
|
</span>
|
|
{action}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function metadataString(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string {
|
|
const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`;
|
|
return engine === 'postgresql'
|
|
? `SELECT * FROM ${tableName} LIMIT 100;`
|
|
: engine === 'mysql'
|
|
? `SELECT * FROM ${tableName} LIMIT 100;`
|
|
: `SELECT TOP (100) * FROM ${tableName};`;
|
|
}
|
|
|
|
function quoteIdentifier(value: string, engine: SqlEngine): string {
|
|
return engine === "mysql"
|
|
? `\`${value.replace(/`/g, "``")}\``
|
|
: engine === "postgresql"
|
|
? `"${value.replace(/"/g, '""')}"`
|
|
: `[${value.replace(/]/g, "]]")}]`;
|
|
}
|
|
|
|
function formatSqlValue(value: unknown): string {
|
|
if (value === null || value === undefined) return "NULL";
|
|
if (typeof value === "object") return JSON.stringify(value);
|
|
return String(value);
|
|
}
|