Feature: Snapshot-Tab in Datasets mit Kontextmenü (Clone/Rename/Rollback/Destroy)
- Snapshots direkt im Datasets-Tab ladbar (lazy, per Pool) - Tabelle: Name, Created, Used, Referenced, Clones - Kontextmenü (⋮) mit Clone, Rename, Roll Back, Destroy Snapshot - Backend: /api/snapshots/clone + /api/snapshots/rename Endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,14 @@ class CreateSnapshotRequest(BaseModel):
|
|||||||
class RollbackSnapshotRequest(BaseModel):
|
class RollbackSnapshotRequest(BaseModel):
|
||||||
snapshot: str
|
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])
|
@router.get("/", response_model=List[Snapshot])
|
||||||
async def list_snapshots(
|
async def list_snapshots(
|
||||||
@@ -96,6 +104,44 @@ async def delete_snapshot(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@router.post("/rollback")
|
||||||
async def rollback_snapshot(
|
async def rollback_snapshot(
|
||||||
request: RollbackSnapshotRequest,
|
request: RollbackSnapshotRequest,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { api, Dataset, SambaShare, NfsShare } from "@/lib/api"
|
|||||||
import { Header } from "@/components/Header"
|
import { Header } from "@/components/Header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog } from "@/components/ui/dialog"
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
@@ -17,6 +18,13 @@ export default function DatasetsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
||||||
const [poolTabs, setPoolTabs] = useState<Map<string, string>>(new Map())
|
const [poolTabs, setPoolTabs] = useState<Map<string, string>>(new Map())
|
||||||
|
const [poolSnapshots, setPoolSnapshots] = useState<Map<string, Snapshot[]>>(new Map())
|
||||||
|
const [loadingSnaps, setLoadingSnaps] = useState<Set<string>>(new Set())
|
||||||
|
const [snapContextMenu, setSnapContextMenu] = useState<{ snap: Snapshot; x: number; y: number } | null>(null)
|
||||||
|
const [renameTarget, setRenameTarget] = useState<Snapshot | null>(null)
|
||||||
|
const [renameValue, setRenameValue] = useState("")
|
||||||
|
const [cloneTarget, setCloneTarget] = useState<Snapshot | null>(null)
|
||||||
|
const [cloneValue, setCloneValue] = useState("")
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
const [showCreateDataset, setShowCreateDataset] = useState(false)
|
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 () => {
|
const handleCreateDataset = async () => {
|
||||||
if (!newDatasetName.trim()) return
|
if (!newDatasetName.trim()) return
|
||||||
try {
|
try {
|
||||||
@@ -357,7 +416,7 @@ export default function DatasetsPage() {
|
|||||||
File Systems ({childDatasets.length + 1})
|
File Systems ({childDatasets.length + 1})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPoolTabs(new Map(poolTabs).set(pool.name, "snapshots"))}
|
onClick={() => handleSnapTab(pool.name)}
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
currentPoolTab === "snapshots"
|
currentPoolTab === "snapshots"
|
||||||
? "border-b-2 border-primary text-primary"
|
? "border-b-2 border-primary text-primary"
|
||||||
@@ -420,11 +479,73 @@ export default function DatasetsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Snapshots Tab */}
|
{/* Snapshots Tab */}
|
||||||
{currentPoolTab === "snapshots" && (
|
{currentPoolTab === "snapshots" && (() => {
|
||||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
const snaps = poolSnapshots.get(pool.name) || []
|
||||||
See Snapshots page for detailed snapshot management
|
const isLoading = loadingSnaps.has(pool.name)
|
||||||
</div>
|
return (
|
||||||
)}
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<span className="text-xs text-muted-foreground">{snaps.length} snapshots</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => loadPoolSnapshots(pool.name)} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`w-3 h-3 mr-1 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={async () => { await api.createSnapshot(pool.name); loadPoolSnapshots(pool.name) }}>
|
||||||
|
<Camera className="w-3 h-3 mr-1" />
|
||||||
|
Create Snapshot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-6 text-muted-foreground text-xs">Loading…</div>
|
||||||
|
) : snaps.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-muted-foreground text-xs">No snapshots</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Created</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Used</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Referenced</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Clones</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snaps.map((snap) => (
|
||||||
|
<tr key={snap.name} className="border-b border-border/50 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-2 font-mono">
|
||||||
|
<span className="text-muted-foreground">{snap.dataset}@</span>{snap.name.split("@")[1]}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{snap.creation_datetime ? new Date(snap.creation_datetime).toLocaleString() : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{formatBytes(snap.used)}</td>
|
||||||
|
<td className="px-3 py-2">{formatBytes(snap.referenced)}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">—</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-muted"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSnapContextMenu({ snap, x: e.clientX, y: e.clientY })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Status Tab */}
|
{/* Status Tab */}
|
||||||
{currentPoolTab === "status" && (
|
{currentPoolTab === "status" && (
|
||||||
@@ -742,6 +863,65 @@ export default function DatasetsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Snapshot Context Menu */}
|
||||||
|
{snapContextMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setSnapContextMenu(null)} />
|
||||||
|
<div
|
||||||
|
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[180px]"
|
||||||
|
style={{ top: snapContextMenu.y, left: snapContextMenu.x }}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-muted ${danger ? "text-destructive" : ""}`}
|
||||||
|
onClick={() => handleSnapAction(action, snapContextMenu.snap)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rename Snapshot Dialog */}
|
||||||
|
<Dialog open={!!renameTarget} onClose={() => setRenameTarget(null)} title="Rename Snapshot">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">New name for <span className="font-mono">{renameTarget?.name.split("@")[1]}</span></p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => 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()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleRenameConfirm} className="flex-1">Rename</Button>
|
||||||
|
<Button onClick={() => setRenameTarget(null)} variant="outline" className="flex-1">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Clone Snapshot Dialog */}
|
||||||
|
<Dialog open={!!cloneTarget} onClose={() => setCloneTarget(null)} title="Clone Snapshot">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">Clone <span className="font-mono">{cloneTarget?.name.split("@")[1]}</span> to:</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cloneValue}
|
||||||
|
onChange={(e) => 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()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleCloneConfirm} className="flex-1">Clone</Button>
|
||||||
|
<Button onClick={() => setCloneTarget(null)} variant="outline" className="flex-1">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,16 @@ export class ZFSManagerAPI {
|
|||||||
return response.data
|
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
|
// Shares — Samba
|
||||||
async getSambaShares(): Promise<SambaShare[]> {
|
async getSambaShares(): Promise<SambaShare[]> {
|
||||||
const response = await this.client.get("/api/shares/samba")
|
const response = await this.client.get("/api/shares/samba")
|
||||||
|
|||||||
Reference in New Issue
Block a user