feat: Complete Issue #11 - Fix backup UI bugs and implement System Settings page
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user