fix: route manufacturing audit logs to shared Postgres audit log
- Manufacturing router now uses shared/audit.log_action (Postgres) instead of the separate manufacturing/audit.py (SQLite mfg_audit_log), so all manufacturing events appear in the Log Viewer - Added log_action calls to 5 previously unlogged endpoints: lifecycle patch, lifecycle create, lifecycle delete, flash asset upload, flash asset note - Removed the now-redundant /manufacturing/audit-log endpoint - Log Viewer restricted to sysadmin only: backend uses require_sysadmin (was require_admin_or_above), frontend adds role guard on the page - Fixed Action badge column clipping: table-layout auto + whiteSpace nowrap so the column sizes to fit the widest badge (Status Change) - Added device_batch entity type to Log Viewer entity labels and filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.postgres import get_pg_session
|
||||
from shared.orm import AuditLog
|
||||
from auth.dependencies import require_admin_or_above
|
||||
from auth.dependencies import require_sysadmin
|
||||
from auth.models import TokenPayload
|
||||
|
||||
router = APIRouter(prefix="/api/audit-log", tags=["audit-log"])
|
||||
@@ -26,7 +26,7 @@ async def list_audit_log(
|
||||
to_date: Optional[datetime] = Query(None),
|
||||
limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_admin_or_above),
|
||||
_user: TokenPayload = Depends(require_sysadmin),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
filters = []
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi.responses import Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
@@ -13,9 +14,10 @@ from manufacturing.models import (
|
||||
ManufacturingStats,
|
||||
)
|
||||
from manufacturing import service
|
||||
from manufacturing import audit
|
||||
from shared.audit import log_action
|
||||
from shared.exceptions import NotFoundError
|
||||
from shared.firebase import get_db as get_firestore
|
||||
from database.postgres import get_pg_session
|
||||
|
||||
|
||||
class LifecycleEntryPatch(BaseModel):
|
||||
@@ -43,26 +45,21 @@ def get_stats(
|
||||
return service.get_stats()
|
||||
|
||||
|
||||
@router.get("/audit-log")
|
||||
async def get_audit_log(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
entries = await audit.get_recent(limit=limit)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
@router.post("/batch", response_model=BatchResponse, status_code=201)
|
||||
async def create_batch(
|
||||
body: BatchCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
result = service.create_batch(body)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="batch_created",
|
||||
detail={
|
||||
"batch_id": result.batch_id,
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="CREATE",
|
||||
entity_type="device_batch",
|
||||
entity_id=result.batch_id,
|
||||
entity_label=f"Batch {result.batch_id} ({result.board_type}, qty {len(result.serial_numbers)})",
|
||||
meta={
|
||||
"board_type": result.board_type,
|
||||
"board_version": result.board_version,
|
||||
"quantity": len(result.serial_numbers),
|
||||
@@ -137,6 +134,7 @@ async def update_status(
|
||||
sn: str,
|
||||
body: DeviceStatusUpdate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
# Guard: claimed requires at least one user in user_list
|
||||
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
|
||||
@@ -167,11 +165,13 @@ async def update_status(
|
||||
)
|
||||
|
||||
result = service.update_device_status(sn, body, set_by=user.email)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="status_updated",
|
||||
serial_number=sn,
|
||||
detail={"status": body.status.value, "note": body.note},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="STATUS_CHANGE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"status": body.status.value, "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -181,10 +181,11 @@ async def patch_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryPatch,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Edit the date and/or note of a lifecycle history entry by index."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
fs = get_firestore()
|
||||
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
@@ -198,7 +199,16 @@ async def patch_lifecycle_entry(
|
||||
history[body.index]["note"] = body.note
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
result = _doc_to_inventory_item(doc_ref.get())
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"lifecycle_index": body.index, "date": body.date, "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200)
|
||||
@@ -206,6 +216,7 @@ async def create_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Upsert a lifecycle history entry for the given status_id.
|
||||
|
||||
@@ -214,8 +225,8 @@ async def create_lifecycle_entry(
|
||||
a status is visited more than once (max one entry per status).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
fs = get_firestore()
|
||||
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
@@ -229,19 +240,28 @@ async def create_lifecycle_entry(
|
||||
"set_by": user.email,
|
||||
}
|
||||
|
||||
# Overwrite existing entry for this status if present, else append
|
||||
existing_idx = next(
|
||||
(i for i, e in enumerate(history) if e.get("status_id") == body.status_id),
|
||||
None,
|
||||
)
|
||||
if existing_idx is not None:
|
||||
is_update = existing_idx is not None
|
||||
if is_update:
|
||||
history[existing_idx] = new_entry
|
||||
else:
|
||||
history.append(new_entry)
|
||||
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
result = _doc_to_inventory_item(doc_ref.get())
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE" if is_update else "CREATE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"lifecycle_status": body.status_id, "date": new_entry["date"], "note": body.note},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
|
||||
@@ -249,10 +269,11 @@ async def delete_lifecycle_entry(
|
||||
sn: str,
|
||||
index: int,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
fs = get_firestore()
|
||||
docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
@@ -261,12 +282,22 @@ async def delete_lifecycle_entry(
|
||||
if index < 0 or index >= len(history):
|
||||
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
|
||||
current_status = data.get("mfg_status", "")
|
||||
if history[index].get("status_id") == current_status:
|
||||
deleted_entry = history[index]
|
||||
if deleted_entry.get("status_id") == current_status:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
|
||||
history.pop(index)
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
result = _doc_to_inventory_item(doc_ref.get())
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"lifecycle_status": deleted_entry.get("status_id"), "index": index},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/nvs.bin")
|
||||
@@ -276,12 +307,16 @@ async def download_nvs(
|
||||
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
|
||||
nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy"))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_flashed",
|
||||
serial_number=sn,
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="COMMAND",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"command": "nvs_flash", "hw_type_override": hw_type_override, "nvs_schema": nvs_schema or "new"},
|
||||
)
|
||||
return Response(
|
||||
content=binary,
|
||||
@@ -295,16 +330,19 @@ async def assign_device(
|
||||
sn: str,
|
||||
body: DeviceAssign,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
try:
|
||||
result = service.assign_device(sn, body)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_assigned",
|
||||
serial_number=sn,
|
||||
detail={"customer_id": body.customer_id},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"customer_id": body.customer_id},
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -314,6 +352,7 @@ async def delete_device(
|
||||
sn: str,
|
||||
force: bool = Query(False, description="Required to delete sold/claimed devices"),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete a device. Sold/claimed devices require force=true."""
|
||||
try:
|
||||
@@ -322,11 +361,13 @@ async def delete_device(
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_deleted",
|
||||
serial_number=sn,
|
||||
detail={"force": force},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"force": force},
|
||||
)
|
||||
|
||||
|
||||
@@ -334,6 +375,7 @@ async def delete_device(
|
||||
async def send_manufactured_email(
|
||||
sn: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Send the 'device manufactured' notification to the assigned customer's email."""
|
||||
db = get_firestore()
|
||||
@@ -361,11 +403,13 @@ async def send_manufactured_email(
|
||||
device_name=hw_family.replace("_", " ").title(),
|
||||
customer_name=customer_name,
|
||||
)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="email_manufactured_sent",
|
||||
serial_number=sn,
|
||||
detail={"recipient": email},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="COMMAND",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"command": "email_manufactured", "recipient": email},
|
||||
)
|
||||
|
||||
|
||||
@@ -373,6 +417,7 @@ async def send_manufactured_email(
|
||||
async def send_assigned_email(
|
||||
sn: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Send the 'device assigned / app instructions' email to the assigned user(s)."""
|
||||
db = get_firestore()
|
||||
@@ -407,24 +452,30 @@ async def send_assigned_email(
|
||||
errors.append(str(exc))
|
||||
if errors:
|
||||
raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}")
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="email_assigned_sent",
|
||||
serial_number=sn,
|
||||
detail={"user_count": len(user_list)},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="COMMAND",
|
||||
entity_type="device",
|
||||
entity_id=sn,
|
||||
entity_label=sn,
|
||||
meta={"command": "email_assigned", "user_count": len(user_list)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/devices", status_code=200)
|
||||
async def delete_unprovisioned(
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete all devices with status 'manufactured' (never provisioned)."""
|
||||
deleted = service.delete_unprovisioned_devices()
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="bulk_delete_unprovisioned",
|
||||
detail={"count": len(deleted), "serial_numbers": deleted},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="device_batch",
|
||||
entity_id="bulk_unprovisioned",
|
||||
entity_label=f"Bulk delete unprovisioned ({len(deleted)} devices)",
|
||||
meta={"count": len(deleted), "serial_numbers": deleted},
|
||||
)
|
||||
return {"deleted": deleted, "count": len(deleted)}
|
||||
|
||||
@@ -466,6 +517,7 @@ async def delete_flash_asset(
|
||||
hw_type: str,
|
||||
asset: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Delete a single flash asset file (bootloader.bin or partitions.bin)."""
|
||||
if asset not in VALID_FLASH_ASSETS:
|
||||
@@ -474,10 +526,13 @@ async def delete_flash_asset(
|
||||
service.delete_flash_asset(hw_type, asset)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="flash_asset_deleted",
|
||||
detail={"hw_type": hw_type, "asset": asset},
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="DELETE",
|
||||
entity_type="firmware",
|
||||
entity_id=f"{hw_type}/{asset}",
|
||||
entity_label=f"{hw_type} / {asset}",
|
||||
meta={"hw_type": hw_type, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
@@ -489,7 +544,8 @@ class FlashAssetNoteBody(BaseModel):
|
||||
async def set_flash_asset_note(
|
||||
hw_type: str,
|
||||
body: FlashAssetNoteBody,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Save (or overwrite) the note for a hw_type's flash asset set.
|
||||
|
||||
@@ -497,6 +553,14 @@ async def set_flash_asset_note(
|
||||
Pass an empty string to clear the note.
|
||||
"""
|
||||
service.set_flash_asset_note(hw_type, body.note)
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="UPDATE",
|
||||
entity_type="firmware",
|
||||
entity_id=hw_type,
|
||||
entity_label=hw_type,
|
||||
meta={"note": body.note},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/flash-assets/{hw_type}/{asset}", status_code=204)
|
||||
@@ -504,7 +568,8 @@ async def upload_flash_asset(
|
||||
hw_type: str,
|
||||
asset: str,
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Upload a bootloader.bin or partitions.bin for a given hw_type.
|
||||
|
||||
@@ -512,13 +577,20 @@ async def upload_flash_asset(
|
||||
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
|
||||
each PlatformIO build that changes the partition layout.
|
||||
"""
|
||||
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
|
||||
if not hw_type or len(hw_type) > 128:
|
||||
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
|
||||
if asset not in VALID_FLASH_ASSETS:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
|
||||
data = await file.read()
|
||||
service.save_flash_asset(hw_type, asset, data)
|
||||
await log_action(
|
||||
db, user.sub, user.email,
|
||||
action="CREATE",
|
||||
entity_type="firmware",
|
||||
entity_id=f"{hw_type}/{asset}",
|
||||
entity_label=f"{hw_type} / {asset}",
|
||||
meta={"hw_type": hw_type, "asset": asset, "size_bytes": len(data)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/bootloader.bin")
|
||||
|
||||
@@ -35,6 +35,7 @@ const ENTITY_LABELS = {
|
||||
customer: 'Customer',
|
||||
order: 'Order',
|
||||
device: 'Device',
|
||||
device_batch: 'Device Batch',
|
||||
melody: 'Melody',
|
||||
product: 'Product',
|
||||
staff: 'Staff',
|
||||
@@ -246,7 +247,7 @@ function LogRow({ entry, isExpanded, onToggle }) {
|
||||
</td>
|
||||
|
||||
{/* Action badge */}
|
||||
<td className="log-cell">
|
||||
<td className="log-cell" style={{ whiteSpace: 'nowrap' }}>
|
||||
<StatusBadge variant={variant}>{label}</StatusBadge>
|
||||
</td>
|
||||
|
||||
@@ -492,9 +493,7 @@ const INITIAL_FILTERS = {
|
||||
offset: 0,
|
||||
}
|
||||
|
||||
export default function LogViewerPage() {
|
||||
const { user } = useAuth()
|
||||
|
||||
function LogViewerContent() {
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -592,7 +591,7 @@ export default function LogViewerPage() {
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
table-layout: auto;
|
||||
}
|
||||
.log-table thead th {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
@@ -668,7 +667,7 @@ export default function LogViewerPage() {
|
||||
<tr>
|
||||
<th style={{ width: 148 }}>Timestamp</th>
|
||||
<th style={{ width: 160 }}>Staff</th>
|
||||
<th style={{ width: 110 }}>Action</th>
|
||||
<th style={{ whiteSpace: 'nowrap' }}>Action</th>
|
||||
<th>Entity</th>
|
||||
<th style={{ width: 120 }}>ID</th>
|
||||
<th style={{ width: 40 }} />
|
||||
@@ -752,3 +751,20 @@ export default function LogViewerPage() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LogViewerPage() {
|
||||
const { hasRole } = useAuth()
|
||||
|
||||
if (!hasRole('sysadmin')) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Log Viewer" subtitle="Staff actions and system events across the console" />
|
||||
<div style={{ padding: 'var(--space-12)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||
Access restricted to system administrators.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <LogViewerContent />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user