feat: Complete Issue #11 - Fix backup UI bugs and implement System Settings page

This commit is contained in:
Wikid82
2025-11-20 13:38:05 -05:00
parent 042082fa87
commit 20c8944380
5 changed files with 254 additions and 12 deletions
+3 -1
View File
@@ -10,6 +10,7 @@ import RemoteServers from './pages/RemoteServers'
import ImportCaddy from './pages/ImportCaddy'
import Certificates from './pages/Certificates'
import SettingsLayout from './pages/SettingsLayout'
import SystemSettings from './pages/SystemSettings'
import Security from './pages/Security'
import Backups from './pages/Backups'
import Logs from './pages/Logs'
@@ -40,7 +41,8 @@ export default function App() {
{/* Settings Routes */}
<Route path="settings" element={<SettingsLayout />}>
<Route index element={<Security />} /> {/* Default to Security */}
<Route index element={<SystemSettings />} /> {/* Default to System */}
<Route path="system" element={<SystemSettings />} />
<Route path="security" element={<Security />} />
<Route path="tasks">
<Route path="backups" element={<Backups />} />
+4 -8
View File
@@ -1,9 +1,9 @@
import client from './client';
export interface BackupFile {
name: string;
filename: string;
size: number;
mod_time: string;
time: string;
}
export const getBackups = async (): Promise<BackupFile[]> => {
@@ -20,10 +20,6 @@ export const restoreBackup = async (filename: string): Promise<void> => {
await client.post(`/backups/${filename}/restore`);
};
export const deleteBackup = async (_filename: string): Promise<void> => {
// Note: Delete endpoint wasn't explicitly asked for in the backend implementation plan,
// but it's good practice. I'll skip implementing the API call for now if the backend doesn't support it yet
// to avoid 404s, but I should probably add it to the backend later.
// For now, let's stick to what we built.
throw new Error("Not implemented");
export const deleteBackup = async (filename: string): Promise<void> => {
await client.delete(`/backups/${filename}`);
};
+8 -2
View File
@@ -8,6 +8,12 @@ import { getBackups, createBackup, restoreBackup, deleteBackup } from '../api/ba
import { getSettings, updateSetting } from '../api/settings'
import { Loader2, Download, RotateCcw, Plus, Archive, Trash2, Save } from 'lucide-react'
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
export default function Backups() {
const queryClient = useQueryClient()
const [interval, setInterval] = useState('7')
@@ -165,10 +171,10 @@ export default function Backups() {
{backup.filename}
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{(backup.size / 1024 / 1024).toFixed(2)} MB
{formatSize(backup.size)}
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{new Date(backup.created_at).toLocaleString()}
{new Date(backup.time).toLocaleString()}
</td>
<td className="px-6 py-4 text-right space-x-2">
<Button
+12 -1
View File
@@ -1,5 +1,5 @@
import { Outlet, Link, useLocation } from 'react-router-dom'
import { Shield, Archive, FileText, ChevronDown, ChevronRight } from 'lucide-react'
import { Shield, Archive, FileText, ChevronDown, ChevronRight, Server } from 'lucide-react'
import { useState } from 'react'
export default function SettingsLayout() {
@@ -17,6 +17,17 @@ export default function SettingsLayout() {
Settings
</h2>
<nav className="space-y-1">
<Link
to="/settings/system"
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive('/settings/system')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<Server className="w-4 h-4" />
System
</Link>
<Link
to="/settings/security"
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
+227
View File
@@ -0,0 +1,227 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { toast } from '../components/Toast'
import { getSettings, updateSetting } from '../api/settings'
import client from '../api/client'
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
interface HealthResponse {
status: string
service: string
version: string
git_commit: string
build_time: string
}
interface UpdateInfo {
current_version: string
latest_version: string
update_available: boolean
release_url?: string
}
export default function SystemSettings() {
const queryClient = useQueryClient()
const [caddyEmail, setCaddyEmail] = useState('')
const [caddyAdminAPI, setCaddyAdminAPI] = useState('http://localhost:2019')
// Fetch Settings
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: getSettings,
})
// Update local state when settings load
useState(() => {
if (settings) {
if (settings['caddy.email']) setCaddyEmail(settings['caddy.email'])
if (settings['caddy.admin_api']) setCaddyAdminAPI(settings['caddy.admin_api'])
}
})
// Fetch Health/System Status
const { data: health, isLoading: isLoadingHealth } = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<HealthResponse> => {
const response = await client.get<HealthResponse>('/health')
return response.data
},
})
// Check for Updates
const {
data: updateInfo,
refetch: checkUpdates,
isFetching: isCheckingUpdates,
} = useQuery({
queryKey: ['updates'],
queryFn: async (): Promise<UpdateInfo> => {
const response = await client.get<UpdateInfo>('/system/updates')
return response.data
},
enabled: false, // Manual trigger
})
const saveSettingsMutation = useMutation({
mutationFn: async () => {
await updateSetting('caddy.email', caddyEmail, 'caddy', 'string')
await updateSetting('caddy.admin_api', caddyAdminAPI, 'caddy', 'string')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
toast.success('System settings saved')
},
onError: (error: any) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-8 h-8" />
System Settings
</h1>
{/* General Configuration */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">General Configuration</h2>
<div className="space-y-4">
<Input
label="Default Certificate Email"
type="email"
value={caddyEmail}
onChange={(e) => setCaddyEmail(e.target.value)}
placeholder="admin@example.com"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 -mt-2">
Email address for Let's Encrypt certificate notifications
</p>
<Input
label="Caddy Admin API Endpoint"
type="text"
value={caddyAdminAPI}
onChange={(e) => setCaddyAdminAPI(e.target.value)}
placeholder="http://localhost:2019"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 -mt-2">
URL to the Caddy admin API (usually on port 2019)
</p>
<div className="flex justify-end">
<Button
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
>
<Save className="w-4 h-4 mr-2" />
Save Settings
</Button>
</div>
</div>
</Card>
{/* System Status */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
<Activity className="w-5 h-5" />
System Status
</h2>
{isLoadingHealth ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
</div>
) : health ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Service</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.service}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
<p className="text-lg font-medium text-green-600 dark:text-green-400 capitalize">
{health.status}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Version</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.version}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Build Time</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{health.build_time || 'N/A'}
</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-gray-500 dark:text-gray-400">Git Commit</p>
<p className="text-sm font-mono text-gray-900 dark:text-white">
{health.git_commit || 'N/A'}
</p>
</div>
</div>
) : (
<p className="text-red-500">Unable to fetch system status</p>
)}
</Card>
{/* Update Check */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Software Updates</h2>
<div className="space-y-4">
{updateInfo && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Current Version</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{updateInfo.current_version}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Latest Version</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{updateInfo.latest_version}
</p>
</div>
{updateInfo.update_available && (
<div className="md:col-span-2">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-blue-800 dark:text-blue-300 font-medium">
A new version is available!
</p>
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
View Release Notes
</a>
)}
</div>
</div>
)}
{!updateInfo.update_available && (
<div className="md:col-span-2">
<p className="text-green-600 dark:text-green-400">
You are running the latest version
</p>
</div>
)}
</div>
)}
<Button
onClick={() => checkUpdates()}
isLoading={isCheckingUpdates}
variant="secondary"
>
<RefreshCw className="w-4 h-4 mr-2" />
Check for Updates
</Button>
</div>
</Card>
</div>
)
}