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.
This commit is contained in:
2026-09-30 20:04:16 +08:00
committed by GitHub
parent 092b3b2831
commit daa38d163e
45 changed files with 2603 additions and 1268 deletions
+227 -225
View File
@@ -1,8 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { FileSearch, Upload } from "lucide-react";
import { FolderOpen, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
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";
@@ -212,6 +218,9 @@ export function RestorePage({ connectionId }: RestorePageProps) {
? 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..."
@@ -225,234 +234,227 @@ export function RestorePage({ connectionId }: RestorePageProps) {
? "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 flex-col h-full">
<div className="flex h-full flex-col">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<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>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
{checkingMessage}
</p>
</div>
)}
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 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">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre>
</div>
)}
<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 && (
<>
{isMysql && (
<p className="text-[11px] text-text-muted/70 -mb-2">
Restores run in-process over the driver connection — no MySQL client
installation required.
</p>
)}
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format (PostgreSQL only) */}
{isPg && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
)}
{/* Backup file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Backup File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FileSearch size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Clean toggle */}
{(isPg || isMysql || isSqlite) && (
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
isPg && format === "plain"
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
{isMysql ? (
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
drops only the objects this file defines
</code>
</span>
) : (
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
)}
</label>
)}
{isPg && format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3">
Plain SQL restores run via psql and don't
support DROP-before-CREATE. Use Custom
Archive for clean restores.
</p>
)}
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded 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="mt-2 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">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartRestore}
disabled={!canStart}
>
<Upload size={14} className="mr-1.5" />
{isRunning
? "Restoring..."
: "Start Restore"}
</Button>
</div>
</>
<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>
);
}
}