* chore: bump version to 0.7.5 (Task 1.1) * feat(db): pure object DDL/search/depend builders (Task 1.2) * feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3) * feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1) * feat(objects): schema CRUD commands + integration test (Task 2.2) * feat(objects): search_objects command (current-schema, all types) (Task 2.3) * feat(objects): get_object_ddl for all browsable types (Task 2.4) * feat(objects): pg_depend object dependencies + schema contents (Task 2.5) * feat(ipc): register object management commands (Task 3.1) * feat(ipc): frontend wrappers + types for object management (Task 3.2) * feat(build): declare bundled pg_tools resources (Task 3.3) * test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4) * feat(ui): Cmd+K object search palette in DB viewer (Task 4.1) * feat(ui): schema CRUD menu + dependency dialog (Task 4.2) * feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3) * feat(ui): dependency check before table drop + bundled-tool status (Task 4.4) * docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1) * ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2) * fix(objects): report pg_rewrite dependencies as the dependent view (pg_class) pg_depend records view dependencies via the view's internal rewrite rule (classid = pg_rewrite). The user-facing dependent object is the VIEW itself, so map that classid to pg_class (name still resolved through ev_class). Fixes the live-PG integration test object_dependencies_for_table_includes_view and makes the dependency dialog readable for view dependents. * test: add idempotent PG integration-test seed script Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the #[ignore] integration tests assert against: users (+users_id_seq), products (3 rows), orders (3 rows), order_summary view, audit_log + get_user functions, user_role enum. Idempotent: DROP + recreate. * fix(build): regenerate multi-resolution icons + roadmap Keychain item - icon.ico previously contained a single 16x16 frame (Windows scaled it up -> blurry taskbar/start-menu icon). Regenerated from the 512px source via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full @2x coverage up to 1024px, PNGs re-rendered from the same source. - Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs a high-DPI entry. - ROADMAP: log the 'Enable Keychain' toggle (currently a form-only placeholder) under Next up -> Connection & credentials. * docs(readme): dynamic version-free download links + 3-col table - release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]' (version-free), so asset filenames are stable across releases: Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe, Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc. - README: both download tables now 3-column (OS | Architecture | Download) and link via GitHub's releases/latest/download/<file> redirect — they always point at the newest published release, no per-release edits. - AGENTS.md: releases checklist updated — download links stay version-free. * docs(readme): platform-per-column download table (macOS | Windows | Linux) Table now mirrors the release layout: one column per OS with a logo row and a download-links row underneath. Links stay version-free via releases/latest/download/<file> (releaseAssetNamePattern in release.yml). * docs(readme): revert download table to clean image-less 3-col layout Logo row was hard to read on GitHub dark mode; the plain OS | Architecture | Download table is cleaner and still uses dynamic releases/latest links.
309 lines
15 KiB
TypeScript
309 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Download, FolderOpen, HardDrive } from "lucide-react";
|
|
import { save } from "@tauri-apps/plugin-dialog";
|
|
import { Button } from "../ui/Button";
|
|
import { BackupProgress } from "./BackupProgress";
|
|
import { useBackupStore } from "../../stores/backupStore";
|
|
import { useNotificationStore } from "../../stores/notificationStore";
|
|
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands";
|
|
import type { PgToolStatus } from "../../lib/types";
|
|
|
|
interface BackupPageProps {
|
|
connectionId: string;
|
|
}
|
|
|
|
type BackupFormat = "plain" | "custom" | "tar" | "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 BackupPage({ connectionId }: BackupPageProps) {
|
|
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(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";
|
|
|
|
// Track our job ID so we only react to jobs we started
|
|
const pendingJobRef = useRef<string | null>(null);
|
|
|
|
// React to job completion/failure via store events
|
|
useEffect(() => {
|
|
if (!pendingJobRef.current || !activeJob) return;
|
|
if (activeJob.id !== pendingJobRef.current) return;
|
|
|
|
if (activeJob.status === "completed") {
|
|
notify("Backup completed successfully", "success");
|
|
pendingJobRef.current = null;
|
|
} else if (activeJob.status === "failed") {
|
|
notify(
|
|
`Backup failed: ${activeJob.error_message || "Unknown error"}`,
|
|
"error",
|
|
);
|
|
pendingJobRef.current = null;
|
|
}
|
|
}, [activeJob, notify]);
|
|
|
|
useEffect(() => {
|
|
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));
|
|
|
|
getSchemas(connectionId)
|
|
.then((schemas) => setAvailableSchemas(schemas))
|
|
.catch(() => setAvailableSchemas([]));
|
|
}, [connectionId]);
|
|
|
|
const handlePickFile = useCallback(async () => {
|
|
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);
|
|
}, [format]);
|
|
|
|
const handleStartBackup = useCallback(async () => {
|
|
if (!filePath) {
|
|
notify("Please select a file path", "error");
|
|
return;
|
|
}
|
|
const jobId = `dump-${Date.now()}`;
|
|
startJob(jobId, "dump");
|
|
pendingJobRef.current = jobId;
|
|
|
|
try {
|
|
// pgDump returns the job ID immediately — completion
|
|
// comes via Tauri events handled by the backupStore
|
|
await pgDump(connectionId, {
|
|
format,
|
|
filePath,
|
|
schema: schema || undefined,
|
|
tables: undefined,
|
|
noOwner,
|
|
});
|
|
} catch (e) {
|
|
// If the command itself fails (e.g. connection not found),
|
|
// the event won't fire — handle here
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
useBackupStore.getState().failJob(jobId, msg);
|
|
}
|
|
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
|
|
|
|
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
|
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Toolbar header */}
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
|
<HardDrive size={14} className="text-accent" />
|
|
<span className="text-xs font-medium text-text">Backup</span>
|
|
<span className="text-[11px] text-text-muted">
|
|
Create a database backup via pg_dump
|
|
</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 && (
|
|
<div className="glass p-4 text-center">
|
|
<p className="text-sm text-text-muted">
|
|
Checking for pg_dump...
|
|
</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">
|
|
pg_dump not found
|
|
</p>
|
|
<p className="text-amber-200/80 text-xs leading-relaxed">
|
|
The PostgreSQL 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()}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{!checkingTools && !toolsMissing && (
|
|
<>
|
|
{/* Configuration card */}
|
|
<div className="p-5 space-y-5">
|
|
{/* Format */}
|
|
<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 as BackupFormat,
|
|
)
|
|
}
|
|
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>
|
|
|
|
{/* Output file */}
|
|
<div className="space-y-1 w-full">
|
|
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
|
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 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"
|
|
>
|
|
<FolderOpen size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Schema (optional) */}
|
|
<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>
|
|
|
|
{/* No-owner toggle */}
|
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
<input
|
|
type="checkbox"
|
|
checked={noOwner}
|
|
onChange={(e) =>
|
|
setNoOwner(e.target.checked)
|
|
}
|
|
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
|
/>
|
|
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
|
No Owner{" "}
|
|
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
|
--no-owner
|
|
</code>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
{activeJob && (
|
|
<div className="px-4">
|
|
<BackupProgress
|
|
progress={activeJob.status === "completed" ? 100 : 50}
|
|
jobType="dump"
|
|
status={activeJob.status}
|
|
errorMessage={activeJob.error_message ?? undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end pb-2 pr-2">
|
|
<Button
|
|
onClick={handleStartBackup}
|
|
disabled={isRunning || !filePath}
|
|
>
|
|
<Download size={14} className="mr-1.5" />
|
|
{isRunning ? "Backing up..." : "Start Backup"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|