Files
gridline/desktop/src/components/db-viewer/RestorePage.tsx
T
adrianbonpin daa38d163e fix(tools): resolve external MySQL/PG client tools correctly (#44)
Addresses #44 (closed after the v0.8.3 release builds are green).

Three independent defects in one shared tool-resolution path: bundled tools were
looked up at the wrong path (so bundled fallback never worked, for PostgreSQL
too), macOS GUI apps inherit launchd's minimal PATH, and only mariadb-dump/mariadb
were probed — never mysqldump/mysql, which is what brew install mysql-client
installs. Both engines now resolve through one headless, cached, timed resolver,
plus a non-blocking client/server version warning.

Also fixes passing MySQL-only CLI options to the bundled MariaDB client, and
carries the tool-page UI polish and the requested #40 icon revert.
2026-09-30 20:04:16 +08:00

461 lines
20 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { FolderOpen, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { CopyButton } from "../ui/CopyButton";
import { BackupProgress } from "./BackupProgress";
import {
FormRow,
FormSectionHeader,
inputClass,
controlClass,
} from "./objects/formRow";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore";
import {
detectPgTools,
pgRestore,
getSchemas,
mysqlRestore,
sqliteRestore,
} from "../../lib/commands";
import type { PgToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps {
connectionId: string;
}
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysql is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return map.darwin;
if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return map.win32;
return map.linux;
}
export function RestorePage({ connectionId }: RestorePageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Restore completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Restore failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
setPgToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) {
// MySQL restore runs in-process over the sqlx driver connection, so
// there is no client to probe for or wait on here (Backup/Sync
// still probe via detectMysqlTools for mysqldump).
setCheckingTools(false);
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => {
const extensions = isPg
? ["dump", "sql", "tar", "custom", "gz"]
: isMysql
? ["sql"]
: ["db", "sqlite", "sql"];
const picked = await open({
multiple: false,
filters: [
{
name: "Backup Files",
extensions,
},
],
});
if (picked && typeof picked === "string") setFilePath(picked);
}, [isPg, isMysql, isSqlite]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
if (isMysql && !database) {
notify("MySQL connection has no database selected", "error");
return;
}
await runWithProgress("restore", () => {
if (isPg) {
return pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
}
if (isMysql) {
return mysqlRestore(connectionId, {
database: database!,
filePath,
clean,
});
}
return sqliteRestore(connectionId, { filePath, clean });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
clean,
schema,
connectionId,
notify,
runWithProgress,
]);
// pg_restore is still required for PostgreSQL. MySQL restore runs in-process
// via the sqlx driver, so a missing mariadb/mysql client must not gate it —
// that client could never authenticate to MySQL 8 anyway (#41 follow-up).
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_restore_found
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled"
: false;
const canStart = filePath && confirmed && !isRunning;
const installInstructions = getPlatformInstructions(
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
);
const checkingMessage = isPg
? "Checking for pg_restore..."
: isMysql
? "Checking for mysql..."
: null;
const headerDescription = isPg
? "Restore a database from a backup file"
: isMysql
? "Restore a database from a SQL dump"
: "Restore a database from a backup file";
const cleanDisabled = isPg && format === "plain";
const restoreToolResolvedName =
pgToolStatus?.pg_restore_resolved_name ?? "pg_restore";
return (
<div className="flex h-full flex-col">
{/* Toolbar header */}
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted">
{headerDescription}
</span>
</div>
<div className="flex items-center gap-2">
{!checkingTools && !toolsMissing && (
<button
type="button"
onClick={() => void handleStartRestore()}
disabled={!canStart}
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<Upload size={14} className="mr-1.5" />
{isRunning ? "Restoring..." : "Start Restore"}
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="px-4 py-3 text-sm text-text-muted">
{checkingMessage}
</div>
)}
{toolsMissing && !toolsBundled && (
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_restore not found" : "mysql client not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "}
client tools. This build could not locate or run them, so
a manual install is needed:
</p>
{pgToolStatus?.pg_restore_bundled_available && (
<p className="text-amber-200/80 text-xs leading-relaxed">
A bundled copy is present but could not run — reinstall
Gridline, or install the tools below.
</p>
)}
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-amber-200/60">
Install
</span>
<CopyButton
text={installInstructions}
className="text-amber-200/70 hover:text-amber-100"
/>
</div>
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
{installInstructions}
</pre>
</div>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Restore tool */}
{isPg && pgToolStatus?.pg_restore_found && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Using{" "}
{pgToolStatus.pg_restore_source === "bundled"
? "bundled"
: "system"}{" "}
{restoreToolResolvedName}
</span>
</FormRow>
)}
{isMysql && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Restores run in-process over the driver connection —
no MySQL client installation required
</span>
</FormRow>
)}
{/* Format (PostgreSQL only) */}
{isPg && (
<FormRow label="Format">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="custom">Custom Archive</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">Directory</option>
</select>
</FormRow>
)}
{/* Backup file */}
<FormRow label="Backup file">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className={inputClass}
/>
<button
type="button"
onClick={handlePickFile}
aria-label="Browse for file"
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
>
<FolderOpen size={13} />
</button>
</FormRow>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<FormRow label="Schema">
<select
value={schema}
onChange={(e) => setSchema(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</FormRow>
)}
<FormSectionHeader label="Options" />
{/* Clean toggle */}
<FormRow label="Clean">
<label
className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
cleanDisabled
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) => setClean(e.target.checked)}
disabled={cleanDisabled}
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
{isMysql
? "drops only the objects this file defines"
: "DROP before CREATE"}
</code>
</label>
</FormRow>
{isPg && format === "plain" && (
<div className="border-b border-border px-4 py-2 text-[11px] text-text-muted/70">
Plain SQL restores run via psql and don't support
DROP-before-CREATE. Use Custom Archive for clean
restores.
</div>
)}
{/* Destructive confirmation */}
<div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on the target
database. This action cannot be undone.
</span>
</label>
{isMysql && (
<p className="text-[11px] text-red-400/80">
MySQL DDL commits as it runs and can't be rolled
back — a failed restore may leave this database
partially applied.
</p>
)}
</div>
{/* Progress */}
{activeJob && (
<div className="px-4 py-3">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
</>
)}
</div>
</div>
);
}