Initial YakPanel commit
This commit is contained in:
395
YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
Normal file
395
YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
apiRequest,
|
||||
listBackupPlans,
|
||||
createBackupPlan,
|
||||
updateBackupPlan,
|
||||
deleteBackupPlan,
|
||||
runScheduledBackups,
|
||||
} from '../api/client'
|
||||
import { Plus, Trash2, Play, Pencil } from 'lucide-react'
|
||||
|
||||
interface BackupPlanRecord {
|
||||
id: number
|
||||
name: string
|
||||
plan_type: string
|
||||
target_id: number
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SiteRecord {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DbRecord {
|
||||
id: number
|
||||
name: string
|
||||
db_type: string
|
||||
}
|
||||
|
||||
export function BackupPlansPage() {
|
||||
const [plans, setPlans] = useState<BackupPlanRecord[]>([])
|
||||
const [sites, setSites] = useState<SiteRecord[]>([])
|
||||
const [databases, setDatabases] = useState<DbRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editPlan, setEditPlan] = useState<BackupPlanRecord | null>(null)
|
||||
const [editPlanType, setEditPlanType] = useState<'site' | 'database'>('site')
|
||||
const [runLoading, setRunLoading] = useState(false)
|
||||
const [runResults, setRunResults] = useState<{ plan: string; status: string; msg?: string }[] | null>(null)
|
||||
const [createPlanType, setCreatePlanType] = useState<'site' | 'database'>('site')
|
||||
|
||||
const loadPlans = () => {
|
||||
listBackupPlans()
|
||||
.then(setPlans)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
listBackupPlans(),
|
||||
apiRequest<SiteRecord[]>('/site/list'),
|
||||
apiRequest<DbRecord[]>('/database/list'),
|
||||
])
|
||||
.then(([p, s, d]) => {
|
||||
setPlans(p)
|
||||
setSites(s)
|
||||
setDatabases(d.filter((x) => ['MySQL', 'PostgreSQL', 'MongoDB'].includes(x.db_type)))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const plan_type = (form.elements.namedItem('plan_type') as HTMLSelectElement).value as 'site' | 'database'
|
||||
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
|
||||
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
|
||||
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
|
||||
|
||||
if (!name || !schedule || !target_id) {
|
||||
setError('Name, target and schedule are required')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
createBackupPlan({ name, plan_type, target_id, schedule, enabled })
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadPlans()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete backup plan "${name}"?`)) return
|
||||
deleteBackupPlan(id)
|
||||
.then(loadPlans)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleEdit = (plan: BackupPlanRecord) => {
|
||||
setEditPlan(plan)
|
||||
setEditPlanType(plan.plan_type as 'site' | 'database')
|
||||
}
|
||||
|
||||
const handleUpdate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!editPlan) return
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('edit_name') as HTMLInputElement).value.trim()
|
||||
const plan_type = editPlanType
|
||||
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
|
||||
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
|
||||
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
|
||||
|
||||
if (!name || !schedule || !target_id) return
|
||||
updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
|
||||
.then(() => {
|
||||
setEditPlan(null)
|
||||
loadPlans()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleRunScheduled = () => {
|
||||
setRunLoading(true)
|
||||
setRunResults(null)
|
||||
runScheduledBackups()
|
||||
.then((r) => setRunResults(r.results))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setRunLoading(false))
|
||||
}
|
||||
|
||||
const getTargetName = (plan: BackupPlanRecord) => {
|
||||
if (plan.plan_type === 'site') {
|
||||
const s = sites.find((x) => x.id === plan.target_id)
|
||||
return s ? s.name : `#${plan.target_id}`
|
||||
}
|
||||
const d = databases.find((x) => x.id === plan.target_id)
|
||||
return d ? d.name : `#${plan.target_id}`
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Backup Plans</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRunScheduled}
|
||||
disabled={runLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{runLoading ? 'Running...' : 'Run Scheduled'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runResults && runResults.length > 0 && (
|
||||
<div className="mb-4 p-4 rounded bg-gray-100 dark:bg-gray-700">
|
||||
<h3 className="font-medium mb-2">Last run results</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{runResults.map((r, i) => (
|
||||
<li key={i}>
|
||||
{r.plan}: {r.status === 'ok' ? '✓' : r.status === 'skipped' ? '⊘' : '✗'} {r.msg || ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Schedule automated backups. Add a cron entry (e.g. <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">0 * * * *</code> hourly) to call{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">POST /api/v1/backup/run-scheduled</code> with your auth token.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Target</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Schedule</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{plans.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No backup plans. Click "Add Plan" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
plans.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{p.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.plan_type}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{getTargetName(p)}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm">{p.schedule}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.enabled ? 'Yes' : 'No'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => handleEdit(p)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editPlan && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Edit Backup Plan</h2>
|
||||
<form onSubmit={handleUpdate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
name="edit_name"
|
||||
type="text"
|
||||
defaultValue={editPlan.name}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
|
||||
<select
|
||||
value={editPlanType}
|
||||
onChange={(e) => setEditPlanType(e.target.value as 'site' | 'database')}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="site">Site</option>
|
||||
<option value="database">Database</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Target</label>
|
||||
<select
|
||||
name="edit_target_id"
|
||||
defaultValue={
|
||||
editPlanType === editPlan.plan_type
|
||||
? editPlan.target_id
|
||||
: editPlanType === 'site'
|
||||
? sites[0]?.id
|
||||
: databases[0]?.id
|
||||
}
|
||||
key={editPlanType}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
>
|
||||
{editPlanType === 'site'
|
||||
? sites.map((s) => (
|
||||
<option key={`s-${s.id}`} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))
|
||||
: databases.map((d) => (
|
||||
<option key={`d-${d.id}`} value={d.id}>
|
||||
{d.name} ({d.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
|
||||
<input
|
||||
name="edit_schedule"
|
||||
type="text"
|
||||
defaultValue={editPlan.schedule}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="rounded" />
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={() => setEditPlan(null)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Backup Plan</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Daily site backup"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
|
||||
<select
|
||||
name="plan_type"
|
||||
value={createPlanType}
|
||||
onChange={(e) => setCreatePlanType(e.target.value as 'site' | 'database')}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="site">Site</option>
|
||||
<option value="database">Database</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Target</label>
|
||||
<select
|
||||
name="target_id"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{createPlanType === 'site'
|
||||
? sites.map((s) => (
|
||||
<option key={`s-${s.id}`} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))
|
||||
: databases.map((d) => (
|
||||
<option key={`d-${d.id}`} value={d.id}>
|
||||
{d.name} ({d.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
|
||||
<input
|
||||
name="schedule"
|
||||
type="text"
|
||||
placeholder="0 2 * * *"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input name="enabled" type="checkbox" defaultChecked className="rounded" />
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user