diff --git a/backend/routers/snapshots.py b/backend/routers/snapshots.py index 98545bd..fa86141 100644 --- a/backend/routers/snapshots.py +++ b/backend/routers/snapshots.py @@ -35,6 +35,14 @@ class CreateSnapshotRequest(BaseModel): class RollbackSnapshotRequest(BaseModel): snapshot: str +class CloneSnapshotRequest(BaseModel): + snapshot: str + target: str # full target dataset name + +class RenameSnapshotRequest(BaseModel): + snapshot: str + new_name: str # new snapshot tag (after @) + @router.get("/", response_model=List[Snapshot]) async def list_snapshots( @@ -96,6 +104,44 @@ async def delete_snapshot( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/clone") +async def clone_snapshot( + request: CloneSnapshotRequest, + current_user: str = Depends(get_current_user) +): + """Clone snapshot to a new dataset""" + try: + result = zfs_runner.run_command(["zfs", "clone", request.snapshot, request.target]) + if result[2] != 0: + raise HTTPException(status_code=400, detail=result[1]) + zfs_runner.clear_cache() + return {"status": "success", "message": f"Cloned to {request.target}"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/rename") +async def rename_snapshot( + request: RenameSnapshotRequest, + current_user: str = Depends(get_current_user) +): + """Rename snapshot""" + try: + dataset = request.snapshot.split("@")[0] + new_full = f"{dataset}@{request.new_name}" + result = zfs_runner.run_command(["zfs", "rename", request.snapshot, new_full]) + if result[2] != 0: + raise HTTPException(status_code=400, detail=result[1]) + zfs_runner.clear_cache() + return {"status": "success", "message": f"Renamed to {new_full}"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/rollback") async def rollback_snapshot( request: RollbackSnapshotRequest, diff --git a/frontend/app/datasets/page.tsx b/frontend/app/datasets/page.tsx index b14a8aa..4f700ba 100644 --- a/frontend/app/datasets/page.tsx +++ b/frontend/app/datasets/page.tsx @@ -5,7 +5,8 @@ import { api, Dataset, SambaShare, NfsShare } from "@/lib/api" import { Header } from "@/components/Header" import { Button } from "@/components/ui/button" import { Dialog } from "@/components/ui/dialog" -import { Plus, Trash2, RefreshCw, ChevronRight, ChevronDown } from "lucide-react" +import { Plus, Trash2, RefreshCw, ChevronRight, ChevronDown, MoreVertical, Camera } from "lucide-react" +import { Snapshot } from "@/lib/api" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" @@ -17,6 +18,13 @@ export default function DatasetsPage() { const [loading, setLoading] = useState(true) const [expandedDatasets, setExpandedDatasets] = useState>(new Set()) const [poolTabs, setPoolTabs] = useState>(new Map()) + const [poolSnapshots, setPoolSnapshots] = useState>(new Map()) + const [loadingSnaps, setLoadingSnaps] = useState>(new Set()) + const [snapContextMenu, setSnapContextMenu] = useState<{ snap: Snapshot; x: number; y: number } | null>(null) + const [renameTarget, setRenameTarget] = useState(null) + const [renameValue, setRenameValue] = useState("") + const [cloneTarget, setCloneTarget] = useState(null) + const [cloneValue, setCloneValue] = useState("") // Dialogs const [showCreateDataset, setShowCreateDataset] = useState(false) @@ -57,6 +65,57 @@ export default function DatasetsPage() { } } + const loadPoolSnapshots = async (poolName: string) => { + setLoadingSnaps((s) => new Set(s).add(poolName)) + try { + const snaps = await api.getSnapshots(poolName, 200) + setPoolSnapshots((m) => new Map(m).set(poolName, snaps)) + } catch (err) { + console.error("Failed to load snapshots:", err) + } finally { + setLoadingSnaps((s) => { const n = new Set(s); n.delete(poolName); return n }) + } + } + + const handleSnapTab = (poolName: string) => { + setPoolTabs(new Map(poolTabs).set(poolName, "snapshots")) + if (!poolSnapshots.has(poolName)) loadPoolSnapshots(poolName) + } + + const handleSnapAction = async (action: string, snap: Snapshot) => { + setSnapContextMenu(null) + if (action === "rollback") { + if (!confirm(`Roll back ${snap.dataset} to ${snap.name.split("@")[1]}? This destroys data after this snapshot!`)) return + await api.rollbackSnapshot(snap.name) + loadPoolSnapshots(snap.name.split("@")[0].split("/")[0]) + } else if (action === "destroy") { + if (!confirm(`Destroy snapshot ${snap.name.split("@")[1]}?`)) return + await api.deleteSnapshot(snap.name) + const pool = snap.name.split("@")[0].split("/")[0] + loadPoolSnapshots(pool) + } else if (action === "rename") { + setRenameValue(snap.name.split("@")[1]) + setRenameTarget(snap) + } else if (action === "clone") { + setCloneValue(`${snap.dataset}-clone`) + setCloneTarget(snap) + } + } + + const handleRenameConfirm = async () => { + if (!renameTarget || !renameValue.trim()) return + await api.renameSnapshot(renameTarget.name, renameValue.trim()) + loadPoolSnapshots(renameTarget.name.split("@")[0].split("/")[0]) + setRenameTarget(null) + } + + const handleCloneConfirm = async () => { + if (!cloneTarget || !cloneValue.trim()) return + await api.cloneSnapshot(cloneTarget.name, cloneValue.trim()) + loadData() + setCloneTarget(null) + } + const handleCreateDataset = async () => { if (!newDatasetName.trim()) return try { @@ -357,7 +416,7 @@ export default function DatasetsPage() { File Systems ({childDatasets.length + 1}) + + + + {isLoading ? ( +
Loading…
+ ) : snaps.length === 0 ? ( +
No snapshots
+ ) : ( +
+ + + + + + + + + + + + + {snaps.map((snap) => ( + + + + + + + + + ))} + +
NameCreatedUsedReferencedClones
+ {snap.dataset}@{snap.name.split("@")[1]} + + {snap.creation_datetime ? new Date(snap.creation_datetime).toLocaleString() : "—"} + {formatBytes(snap.used)}{formatBytes(snap.referenced)} + +
+
+ )} + + ) + })()} {/* Status Tab */} {currentPoolTab === "status" && ( @@ -742,6 +863,65 @@ export default function DatasetsPage() { + + {/* Snapshot Context Menu */} + {snapContextMenu && ( + <> +
setSnapContextMenu(null)} /> +
+ {[ + { action: "clone", label: "Clone Snapshot" }, + { action: "rename", label: "Rename Snapshot" }, + { action: "rollback", label: "Roll Back Snapshot" }, + { action: "destroy", label: "Destroy Snapshot", danger: true }, + ].map(({ action, label, danger }) => ( + + ))} +
+ + )} + + {/* Rename Snapshot Dialog */} + setRenameTarget(null)} title="Rename Snapshot"> +

New name for {renameTarget?.name.split("@")[1]}

+ setRenameValue(e.target.value)} + className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono" + onKeyDown={(e) => e.key === "Enter" && handleRenameConfirm()} + /> +
+ + +
+
+ + {/* Clone Snapshot Dialog */} + setCloneTarget(null)} title="Clone Snapshot"> +

Clone {cloneTarget?.name.split("@")[1]} to:

+ setCloneValue(e.target.value)} + placeholder="target dataset name" + className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono" + onKeyDown={(e) => e.key === "Enter" && handleCloneConfirm()} + /> +
+ + +
+
) } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 66bd470..5a97596 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -267,6 +267,16 @@ export class ZFSManagerAPI { return response.data } + async cloneSnapshot(snapshot: string, target: string): Promise<{ status: string }> { + const response = await this.client.post("/api/snapshots/clone", { snapshot, target }) + return response.data + } + + async renameSnapshot(snapshot: string, new_name: string): Promise<{ status: string }> { + const response = await this.client.post("/api/snapshots/rename", { snapshot, new_name }) + return response.data + } + // Shares — Samba async getSambaShares(): Promise { const response = await this.client.get("/api/shares/samba")