Files
floci-ui/packages/frontend/src/components/AzureSqlPanel.tsx
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

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 TheCloud'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 TheCloud'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);
}