feat(settings): add log retention settings page
Sysadmin/admin-only settings page for configuring how long device log history is kept before pruning. GET/PUT /api/settings/log-retention, new LogRetentionSettings page, nav entry, and route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class LogRetentionSettings(BaseModel):
|
||||||
|
days: int = 180
|
||||||
|
|
||||||
|
|
||||||
|
class LogRetentionSettingsUpdate(BaseModel):
|
||||||
|
days: Optional[int] = None
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from shared.firebase import get_db
|
||||||
|
from settings.log_retention_models import LogRetentionSettings, LogRetentionSettingsUpdate
|
||||||
|
|
||||||
|
COLLECTION = "admin_settings"
|
||||||
|
DOC_ID = "log_retention"
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_retention() -> LogRetentionSettings:
|
||||||
|
"""Get log retention settings from Firestore. Creates defaults if not found."""
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection(COLLECTION).document(DOC_ID).get()
|
||||||
|
if doc.exists:
|
||||||
|
return LogRetentionSettings(**doc.to_dict())
|
||||||
|
defaults = LogRetentionSettings()
|
||||||
|
db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump())
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def update_log_retention(data: LogRetentionSettingsUpdate) -> LogRetentionSettings:
|
||||||
|
"""Update log retention settings. Only provided fields are updated."""
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(DOC_ID)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
|
||||||
|
existing = doc.to_dict() if doc.exists else LogRetentionSettings().model_dump()
|
||||||
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
existing.update(update_data)
|
||||||
|
|
||||||
|
normalized = LogRetentionSettings(**existing)
|
||||||
|
doc_ref.set(normalized.model_dump())
|
||||||
|
return normalized
|
||||||
@@ -4,8 +4,10 @@ from auth.dependencies import require_permission, require_roles
|
|||||||
from auth.models import Role
|
from auth.models import Role
|
||||||
from settings.models import MelodySettings, MelodySettingsUpdate
|
from settings.models import MelodySettings, MelodySettingsUpdate
|
||||||
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
|
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
|
||||||
|
from settings.log_retention_models import LogRetentionSettings, LogRetentionSettingsUpdate
|
||||||
from settings import service
|
from settings import service
|
||||||
from settings import public_features_service
|
from settings import public_features_service
|
||||||
|
from settings import log_retention_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -40,3 +42,20 @@ async def update_public_features(
|
|||||||
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
||||||
):
|
):
|
||||||
return public_features_service.update_public_features(body)
|
return public_features_service.update_public_features(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Log Retention Settings (sysadmin / admin only) ───────────────────────────
|
||||||
|
|
||||||
|
@router.get("/log-retention", response_model=LogRetentionSettings)
|
||||||
|
async def get_log_retention(
|
||||||
|
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
||||||
|
):
|
||||||
|
return log_retention_service.get_log_retention()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/log-retention", response_model=LogRetentionSettings)
|
||||||
|
async def update_log_retention(
|
||||||
|
body: LogRetentionSettingsUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
||||||
|
):
|
||||||
|
return log_retention_service.update_log_retention(body)
|
||||||
|
|||||||
@@ -292,6 +292,16 @@ const SETTINGS_ITEMS = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/log-retention',
|
||||||
|
label: 'Log Retention',
|
||||||
|
icon: (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="6"/>
|
||||||
|
<path d="M8 5v3l2 1.5"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/pages',
|
to: '/settings/pages',
|
||||||
label: 'Page Settings',
|
label: 'Page Settings',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// frontend/src/pages/settings/LogRetentionSettings.jsx
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import api from '@/lib/api'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
|
import Card from '@/components/ui/Card'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import FormField from '@/components/ui/FormField'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
|
||||||
|
const PRESETS = [30, 90, 180, 365]
|
||||||
|
|
||||||
|
export default function LogRetentionSettings() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const [days, setDays] = useState(180)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/settings/log-retention')
|
||||||
|
.then(data => setDays(data.days))
|
||||||
|
.catch(e => toast.danger('Failed to load settings', e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await api.put('/settings/log-retention', { days: Number(days) })
|
||||||
|
setDays(updated.days)
|
||||||
|
toast.success('Settings saved', `Device logs are now retained for ${updated.days} days.`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.danger('Failed to save', e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}>
|
||||||
|
<PageHeader
|
||||||
|
title="Log Retention"
|
||||||
|
subtitle="Controls how long device MQTT logs (Device Logs, Info, Heartbeats, Commands) are kept before automatic deletion."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-12)' }}>
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card variant="flat">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
||||||
|
<FormField
|
||||||
|
label="Retention period (days)"
|
||||||
|
name="retention_days"
|
||||||
|
type="number"
|
||||||
|
value={days}
|
||||||
|
onChange={e => setDays(e.target.value)}
|
||||||
|
hint="Applies globally to every device — there is no per-device override."
|
||||||
|
inputProps={{ min: 1, max: 3650 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||||
|
{PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDays(p)}
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
paddingInline: 'var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: `1px solid ${Number(days) === p ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
|
||||||
|
background: Number(days) === p ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
|
||||||
|
color: Number(days) === p ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p === 30 ? '1 month' : p === 90 ? '3 months' : p === 180 ? '6 months' : '1 year'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import StaffDetail from '@/pages/settings/staff/StaffDetail'
|
|||||||
import StaffForm from '@/pages/settings/staff/StaffForm'
|
import StaffForm from '@/pages/settings/staff/StaffForm'
|
||||||
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
|
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
|
||||||
import LogViewerPage from '@/pages/settings/LogViewerPage'
|
import LogViewerPage from '@/pages/settings/LogViewerPage'
|
||||||
|
import LogRetentionSettings from '@/pages/settings/LogRetentionSettings'
|
||||||
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
|
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
|
||||||
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
|
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
|
||||||
import CustomerList from '@/pages/crm/customers/CustomerList'
|
import CustomerList from '@/pages/crm/customers/CustomerList'
|
||||||
@@ -196,6 +197,7 @@ export default function V2Router() {
|
|||||||
<Route path="settings/public-features" element={<RoleGate roles={['sysadmin', 'admin']}><PublicFeaturesSettings /></RoleGate>} />
|
<Route path="settings/public-features" element={<RoleGate roles={['sysadmin', 'admin']}><PublicFeaturesSettings /></RoleGate>} />
|
||||||
<Route path="settings/automations" element={<RoleGate roles={['sysadmin', 'admin']}><AutomationsPage /></RoleGate>} />
|
<Route path="settings/automations" element={<RoleGate roles={['sysadmin', 'admin']}><AutomationsPage /></RoleGate>} />
|
||||||
<Route path="settings/audit-log" element={<RoleGate roles={['sysadmin']}><LogViewerPage /></RoleGate>} />
|
<Route path="settings/audit-log" element={<RoleGate roles={['sysadmin']}><LogViewerPage /></RoleGate>} />
|
||||||
|
<Route path="settings/log-retention" element={<RoleGate roles={['sysadmin', 'admin']}><LogRetentionSettings /></RoleGate>} />
|
||||||
|
|
||||||
{/* Catch-all */}
|
{/* Catch-all */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user