Files
gridline/desktop/src/components/db-viewer/BackupDialog.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

233 lines
8.9 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { CopyButton } from "../ui/CopyButton";
import { Select } from "../ui/Select";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface BackupDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const FORMAT_OPTIONS = [
{ value: "plain", label: "Plain SQL" },
{ value: "custom", label: "Custom Archive" },
{ value: "tar", label: "Tarball" },
{ value: "directory", label: "Directory" },
];
const PLATFORM_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_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) {
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
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);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ 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));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${format === "custom" ? "dump" : format === "plain" ? "sql" : "tar"}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
});
if (picked) setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, [format]);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
try {
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
notify("Backup completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Backup failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const installInstructions = getPlatformInstructions();
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Backup Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_dump not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</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 rounded p-2 whitespace-pre-wrap">
{installInstructions}
</pre>
</div>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Format selector */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<Select
value={format}
onChange={(v) => setFormat(v as BackupFormat)}
options={FORMAT_OPTIONS}
/>
</div>
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Output 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 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* No owner toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={noOwner}
onChange={(e) => setNoOwner(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
No Owner (--no-owner flag)
</label>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="dump"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartBackup} disabled={running || !filePath}>
{running ? "Backing up..." : "Start Backup"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:")) {
const [, ...rest] = msg.split("pg_dump:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions for the output path.`;
}
return msg;
}