feat: add Settings (user management) and Notifications pages
Backend: - User management API: GET/POST/PUT/DELETE /api/users Supports two roles: developer, server-admin Each user has notifyOnError toggle for email alerts - Notification API: GET /api/notifications, mark read, unread count - Email service: sends alerts via SMTP (mail.bshtech.net/noreply@bshtech.net) to all users with notifyOnError=true on error/warning events - Health monitor: checks CPU, memory, disk, Docker containers every 60s Creates notifications + sends email on threshold breaches Detects container down, high CPU (>90%), memory (>90%), disk (>85%) Sends recovery notifications when issues resolve - File-based JSON storage for users and notifications (data/ directory) - Added nodemailer dependency Frontend: - Settings page: user list with role badges, email alert toggles, add/remove user forms. Roles: Developer (blue), Server Admin (amber) - Notifications page: real-time alert feed with type icons (error/warning/ info/recovery), read/unread state, email sent indicator, time ago display - Added Notifications to sidebar navigation - Wired both pages into App.tsx router Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
|
|||||||
import { MemoryPage } from "@/components/pages/MemoryPage"
|
import { MemoryPage } from "@/components/pages/MemoryPage"
|
||||||
import { StoragePage } from "@/components/pages/StoragePage"
|
import { StoragePage } from "@/components/pages/StoragePage"
|
||||||
import { NetworkPage } from "@/components/pages/NetworkPage"
|
import { NetworkPage } from "@/components/pages/NetworkPage"
|
||||||
|
import { SettingsPage } from "@/components/pages/SettingsPage"
|
||||||
|
import { NotificationsPage } from "@/components/pages/NotificationsPage"
|
||||||
import { LoginPage } from "@/components/ui/animated-characters-login-page"
|
import { LoginPage } from "@/components/ui/animated-characters-login-page"
|
||||||
import { useServerHealth } from "@/hooks/useServerHealth"
|
import { useServerHealth } from "@/hooks/useServerHealth"
|
||||||
import { useCpuLive } from "@/hooks/useCpuLive"
|
import { useCpuLive } from "@/hooks/useCpuLive"
|
||||||
@@ -48,6 +50,8 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
memory: "Memory",
|
memory: "Memory",
|
||||||
storage: "Storage",
|
storage: "Storage",
|
||||||
network: "Network",
|
network: "Network",
|
||||||
|
settings: "Settings",
|
||||||
|
notifications: "Notifications",
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
|
function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
|
||||||
@@ -145,6 +149,10 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
|
|||||||
return <StoragePage disk={disk} />
|
return <StoragePage disk={disk} />
|
||||||
case "network":
|
case "network":
|
||||||
return <NetworkPage containers={containers} nginx={nginx} />
|
return <NetworkPage containers={containers} nginx={nginx} />
|
||||||
|
case "settings":
|
||||||
|
return <SettingsPage />
|
||||||
|
case "notifications":
|
||||||
|
return <NotificationsPage />
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MemoryStick,
|
MemoryStick,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Wifi,
|
Wifi,
|
||||||
|
Bell,
|
||||||
Settings,
|
Settings,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Server,
|
Server,
|
||||||
@@ -24,6 +25,7 @@ const mainNav: NavItem[] = [
|
|||||||
{ id: "memory", label: "Memory", icon: MemoryStick },
|
{ id: "memory", label: "Memory", icon: MemoryStick },
|
||||||
{ id: "storage", label: "Storage", icon: HardDrive },
|
{ id: "storage", label: "Storage", icon: HardDrive },
|
||||||
{ id: "network", label: "Network", icon: Wifi },
|
{ id: "network", label: "Network", icon: Wifi },
|
||||||
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
]
|
]
|
||||||
|
|
||||||
const bottomNav: NavItem[] = [
|
const bottomNav: NavItem[] = [
|
||||||
|
|||||||
169
client/src/components/pages/NotificationsPage.tsx
Normal file
169
client/src/components/pages/NotificationsPage.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import { AlertTriangle, AlertCircle, Info, CheckCircle2, Mail, MailX, CheckCheck } from "lucide-react"
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string
|
||||||
|
type: "error" | "warning" | "info" | "recovery"
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
source: string
|
||||||
|
timestamp: string
|
||||||
|
read: boolean
|
||||||
|
emailSent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = "eventify-auth-token"
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts: RequestInit = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
...opts.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Request failed")
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(ts: string): string {
|
||||||
|
const diff = Date.now() - new Date(ts).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 1) return "Just now"
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
error: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50 border-red-200", badge: "bg-red-100 text-red-700" },
|
||||||
|
warning: { icon: AlertTriangle, color: "text-amber-500", bg: "bg-amber-50 border-amber-200", badge: "bg-amber-100 text-amber-700" },
|
||||||
|
info: { icon: Info, color: "text-blue-500", bg: "bg-blue-50 border-blue-200", badge: "bg-blue-100 text-blue-700" },
|
||||||
|
recovery: { icon: CheckCircle2, color: "text-green-500", bg: "bg-green-50 border-green-200", badge: "bg-green-100 text-green-700" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await apiFetch("/api/notifications?limit=100")
|
||||||
|
setNotifications(data)
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const markRead = async (id: string) => {
|
||||||
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n))
|
||||||
|
try { await apiFetch(`/api/notifications/${id}/read`, { method: "POST" }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
|
||||||
|
try { await apiFetch("/api/notifications/read-all", { method: "POST" }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter(n => !n.read).length
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Notifications
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
|
||||||
|
{unreadCount} unread
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Server alerts and error notifications</p>
|
||||||
|
</div>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={markAllRead}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<CheckCheck className="size-4" />
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notification List */}
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="rounded-xl border bg-card px-5 py-16 text-center">
|
||||||
|
<CheckCircle2 className="mx-auto size-12 text-green-400" />
|
||||||
|
<h3 className="mt-4 font-medium text-foreground">All clear</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">No notifications yet. You'll see alerts here when issues are detected.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notifications.map(n => {
|
||||||
|
const cfg = typeConfig[n.type]
|
||||||
|
const Icon = cfg.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => !n.read && markRead(n.id)}
|
||||||
|
className={`cursor-pointer rounded-xl border p-4 transition-colors ${
|
||||||
|
n.read ? "bg-card border-border" : `${cfg.bg}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`mt-0.5 ${cfg.color}`}>
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">{n.title}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase ${cfg.badge}`}>
|
||||||
|
{n.type}
|
||||||
|
</span>
|
||||||
|
{n.emailSent ? (
|
||||||
|
<Mail className="size-3.5 text-green-500" title="Email sent" />
|
||||||
|
) : (
|
||||||
|
<MailX className="size-3.5 text-muted-foreground" title="Email not sent" />
|
||||||
|
)}
|
||||||
|
{!n.read && (
|
||||||
|
<span className="size-2 rounded-full bg-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{n.message}</p>
|
||||||
|
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{n.source}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{timeAgo(n.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
248
client/src/components/pages/SettingsPage.tsx
Normal file
248
client/src/components/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import { UserPlus, Trash2, Shield, Code, Bell, BellOff, Pencil, Check, X } from "lucide-react"
|
||||||
|
|
||||||
|
interface MonitorUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
role: "developer" | "server-admin"
|
||||||
|
notifyOnError: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = "eventify-auth-token"
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts: RequestInit = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
...opts.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error ?? "Request failed")
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [users, setUsers] = useState<MonitorUser[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState({ email: "", name: "", role: "developer" as MonitorUser["role"], notifyOnError: true })
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await apiFetch("/api/users")
|
||||||
|
setUsers(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!form.email || !form.name) { setError("Email and name are required"); return }
|
||||||
|
try {
|
||||||
|
setError("")
|
||||||
|
await apiFetch("/api/users", { method: "POST", body: JSON.stringify(form) })
|
||||||
|
setShowForm(false)
|
||||||
|
setForm({ email: "", name: "", role: "developer", notifyOnError: true })
|
||||||
|
load()
|
||||||
|
} catch (e) { setError((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (id: string) => {
|
||||||
|
const user = users.find(u => u.id === id)
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
setError("")
|
||||||
|
await apiFetch(`/api/users/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ name: user.name, role: user.role, notifyOnError: user.notifyOnError }),
|
||||||
|
})
|
||||||
|
setEditId(null)
|
||||||
|
load()
|
||||||
|
} catch (e) { setError((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
if (!confirm(`Remove ${name} from the user list?`)) return
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/users/${id}`, { method: "DELETE" })
|
||||||
|
load()
|
||||||
|
} catch (e) { setError((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleNotify = async (id: string) => {
|
||||||
|
const user = users.find(u => u.id === id)
|
||||||
|
if (!user) return
|
||||||
|
setUsers(users.map(u => u.id === id ? { ...u, notifyOnError: !u.notifyOnError } : u))
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/users/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ notifyOnError: !user.notifyOnError }),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
load() // revert on error
|
||||||
|
setError((e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIcon = (role: string) =>
|
||||||
|
role === "server-admin"
|
||||||
|
? <Shield className="size-4 text-amber-500" />
|
||||||
|
: <Code className="size-4 text-blue-500" />
|
||||||
|
|
||||||
|
const roleBadge = (role: string) => (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
role === "server-admin" ? "bg-amber-100 text-amber-700" : "bg-blue-100 text-blue-700"
|
||||||
|
}`}>
|
||||||
|
{roleIcon(role)}
|
||||||
|
{role === "server-admin" ? "Server Admin" : "Developer"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">User Management</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage who can access this dashboard and receive error alerts</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError("")} className="ml-2 font-medium underline">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add User Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="rounded-xl border bg-card p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">New User</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">Email</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">Role</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
value={form.role}
|
||||||
|
onChange={e => setForm({ ...form, role: e.target.value as MonitorUser["role"] })}
|
||||||
|
>
|
||||||
|
<option value="developer">Developer</option>
|
||||||
|
<option value="server-admin">Server Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.notifyOnError}
|
||||||
|
onChange={e => setForm({ ...form, notifyOnError: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Receive error alerts
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleAdd} className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
||||||
|
<Check className="size-4" /> Add
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowForm(false); setError("") }} className="inline-flex items-center gap-1 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted">
|
||||||
|
<X className="size-4" /> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border bg-card">
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b px-5 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span>User</span>
|
||||||
|
<span>Role</span>
|
||||||
|
<span>Alerts</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</div>
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.id} className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-4 border-b px-5 py-4 last:border-b-0">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">{u.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
<div>{roleBadge(u.role)}</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleNotify(u.id)}
|
||||||
|
className={`flex size-8 items-center justify-center rounded-lg transition-colors ${
|
||||||
|
u.notifyOnError ? "text-green-600 hover:bg-green-50" : "text-muted-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
title={u.notifyOnError ? "Receiving alerts" : "Alerts disabled"}
|
||||||
|
>
|
||||||
|
{u.notifyOnError ? <Bell className="size-4" /> : <BellOff className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id, u.name)}
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
title="Remove user"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<div className="px-5 py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No users yet. Add your first user above.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,12 +12,14 @@
|
|||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/ssh2": "^1.15.0",
|
"@types/ssh2": "^1.15.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0"
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { existsSync } from 'fs';
|
|||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { authRouter } from './routes/auth.js';
|
import { authRouter } from './routes/auth.js';
|
||||||
|
import { usersRouter } from './routes/users.js';
|
||||||
import { requireAuth } from './middleware/auth.js';
|
import { requireAuth } from './middleware/auth.js';
|
||||||
import { sshManager } from './ssh/client.js';
|
import { sshManager } from './ssh/client.js';
|
||||||
|
import { createNotification } from './services/notifications.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -26,6 +28,7 @@ app.use(express.json());
|
|||||||
// ── API Routes ──
|
// ── API Routes ──
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
app.use('/api', requireAuth, healthRouter);
|
app.use('/api', requireAuth, healthRouter);
|
||||||
|
app.use('/api', requireAuth, usersRouter);
|
||||||
|
|
||||||
// ── Static file serving for production SPA ──
|
// ── Static file serving for production SPA ──
|
||||||
const clientDistPath = resolve(__dirname, '../../client/dist');
|
const clientDistPath = resolve(__dirname, '../../client/dist');
|
||||||
@@ -44,6 +47,95 @@ const server = app.listen(config.port, () => {
|
|||||||
console.log(`[Server] SSH target: ${config.ssh.user}@${config.ssh.host}`);
|
console.log(`[Server] SSH target: ${config.ssh.user}@${config.ssh.host}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Error monitoring: check health every 60s and alert on issues ──
|
||||||
|
const MONITOR_INTERVAL = 60_000;
|
||||||
|
let previousErrors: Set<string> = new Set();
|
||||||
|
|
||||||
|
async function monitorHealth() {
|
||||||
|
try {
|
||||||
|
const { getSystemInfo } = await import('./services/system.js');
|
||||||
|
const { getDockerInfo } = await import('./services/docker.js');
|
||||||
|
const { getMemoryInfo } = await import('./services/memory.js');
|
||||||
|
const { getDiskInfo } = await import('./services/disk.js');
|
||||||
|
|
||||||
|
const currentErrors: Set<string> = new Set();
|
||||||
|
|
||||||
|
// Check system
|
||||||
|
try {
|
||||||
|
const sys = await getSystemInfo();
|
||||||
|
if (sys.cpuPercent > 90) {
|
||||||
|
const key = 'cpu-high';
|
||||||
|
currentErrors.add(key);
|
||||||
|
if (!previousErrors.has(key)) {
|
||||||
|
await createNotification('warning', 'High CPU Usage', `CPU at ${sys.cpuPercent.toFixed(1)}%`, 'System Monitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const key = 'system-down';
|
||||||
|
currentErrors.add(key);
|
||||||
|
if (!previousErrors.has(key)) {
|
||||||
|
await createNotification('error', 'System Check Failed', (e as Error).message, 'System Monitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check memory
|
||||||
|
try {
|
||||||
|
const mem = await getMemoryInfo();
|
||||||
|
if (mem.usedPercent > 90) {
|
||||||
|
const key = 'memory-high';
|
||||||
|
currentErrors.add(key);
|
||||||
|
if (!previousErrors.has(key)) {
|
||||||
|
await createNotification('warning', 'High Memory Usage', `Memory at ${mem.usedPercent.toFixed(1)}%`, 'System Monitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Check disk
|
||||||
|
try {
|
||||||
|
const disk = await getDiskInfo();
|
||||||
|
if (disk.usedPercent > 85) {
|
||||||
|
const key = 'disk-high';
|
||||||
|
currentErrors.add(key);
|
||||||
|
if (!previousErrors.has(key)) {
|
||||||
|
await createNotification('warning', 'Disk Space Low', `Disk at ${disk.usedPercent.toFixed(1)}% used`, 'System Monitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Check Docker containers
|
||||||
|
try {
|
||||||
|
const containers = await getDockerInfo();
|
||||||
|
for (const c of containers) {
|
||||||
|
if (c.status && !c.status.toLowerCase().includes('up')) {
|
||||||
|
const key = `container-down-${c.name}`;
|
||||||
|
currentErrors.add(key);
|
||||||
|
if (!previousErrors.has(key)) {
|
||||||
|
await createNotification('error', `Container Down: ${c.name}`, `Status: ${c.status}`, 'Docker Monitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Check for recoveries
|
||||||
|
for (const prev of previousErrors) {
|
||||||
|
if (!currentErrors.has(prev)) {
|
||||||
|
await createNotification('recovery', `Recovered: ${prev}`, `The issue "${prev}" has been resolved.`, 'System Monitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousErrors = currentErrors;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Monitor] Health check error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start monitoring after SSH connection is ready (delay 10s)
|
||||||
|
setTimeout(() => {
|
||||||
|
monitorHealth();
|
||||||
|
setInterval(monitorHealth, MONITOR_INTERVAL);
|
||||||
|
console.log('[Monitor] Health monitoring started (60s interval)');
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
// ── Graceful shutdown ──
|
// ── Graceful shutdown ──
|
||||||
async function shutdown(signal: string) {
|
async function shutdown(signal: string) {
|
||||||
console.log(`\n[Server] ${signal} received, shutting down gracefully...`);
|
console.log(`\n[Server] ${signal} received, shutting down gracefully...`);
|
||||||
|
|||||||
99
server/src/routes/users.ts
Normal file
99
server/src/routes/users.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// server/src/routes/users.ts
|
||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getUsers, addUser, updateUser, deleteUser,
|
||||||
|
getNotifications, markNotificationRead, markAllRead, getUnreadCount,
|
||||||
|
} from '../services/notifications.js';
|
||||||
|
|
||||||
|
export const usersRouter = Router();
|
||||||
|
|
||||||
|
// ── User Management ──
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
usersRouter.get('/users', (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getUsers());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
usersRouter.post('/users', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, name, role, notifyOnError } = req.body;
|
||||||
|
if (!email || !name || !role) {
|
||||||
|
res.status(400).json({ error: 'email, name, and role are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['developer', 'server-admin'].includes(role)) {
|
||||||
|
res.status(400).json({ error: 'role must be "developer" or "server-admin"' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = addUser(email, name, role, notifyOnError ?? true);
|
||||||
|
res.status(201).json(user);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/users/:id
|
||||||
|
usersRouter.put('/users/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = updateUser(req.params.id, req.body);
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(404).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
usersRouter.delete('/users/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
deleteUser(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(404).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Notifications ──
|
||||||
|
|
||||||
|
// GET /api/notifications
|
||||||
|
usersRouter.get('/notifications', (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
res.json(getNotifications(limit));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/notifications/unread-count
|
||||||
|
usersRouter.get('/notifications/unread-count', (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ count: getUnreadCount() });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/:id/read
|
||||||
|
usersRouter.post('/notifications/:id/read', (req, res) => {
|
||||||
|
try {
|
||||||
|
markNotificationRead(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/read-all
|
||||||
|
usersRouter.post('/notifications/read-all', (_req, res) => {
|
||||||
|
try {
|
||||||
|
markAllRead();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
192
server/src/services/notifications.ts
Normal file
192
server/src/services/notifications.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// server/src/services/notifications.ts
|
||||||
|
import { createTransport } from 'nodemailer';
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { MonitorUser, Notification, UserRole } from '../types/index.js';
|
||||||
|
|
||||||
|
// ─── Persistent JSON storage (simple file-based) ───
|
||||||
|
const DATA_DIR = resolve(process.cwd(), 'data');
|
||||||
|
const USERS_FILE = resolve(DATA_DIR, 'users.json');
|
||||||
|
const NOTIFICATIONS_FILE = resolve(DATA_DIR, 'notifications.json');
|
||||||
|
|
||||||
|
function ensureDataDir() {
|
||||||
|
const { mkdirSync } = require('fs');
|
||||||
|
try { mkdirSync(DATA_DIR, { recursive: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsers(): MonitorUser[] {
|
||||||
|
ensureDataDir();
|
||||||
|
if (!existsSync(USERS_FILE)) {
|
||||||
|
// Seed with the existing config users
|
||||||
|
const seed: MonitorUser[] = [
|
||||||
|
{ id: randomUUID(), email: 'nafih@bshtechnologies.in', name: 'Nafih', role: 'server-admin', notifyOnError: true, createdAt: new Date().toISOString() },
|
||||||
|
{ id: randomUUID(), email: 'vivek@bshtechnologies.in', name: 'Vivek', role: 'developer', notifyOnError: true, createdAt: new Date().toISOString() },
|
||||||
|
];
|
||||||
|
writeFileSync(USERS_FILE, JSON.stringify(seed, null, 2));
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(USERS_FILE, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUsers(users: MonitorUser[]) {
|
||||||
|
ensureDataDir();
|
||||||
|
writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifications(): Notification[] {
|
||||||
|
ensureDataDir();
|
||||||
|
if (!existsSync(NOTIFICATIONS_FILE)) {
|
||||||
|
writeFileSync(NOTIFICATIONS_FILE, '[]');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(NOTIFICATIONS_FILE, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNotifications(notifs: Notification[]) {
|
||||||
|
ensureDataDir();
|
||||||
|
// Keep only last 200 notifications
|
||||||
|
const trimmed = notifs.slice(-200);
|
||||||
|
writeFileSync(NOTIFICATIONS_FILE, JSON.stringify(trimmed, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SMTP Transport ───
|
||||||
|
const smtpTransport = createTransport({
|
||||||
|
host: process.env.SMTP_HOST ?? 'mail.bshtech.net',
|
||||||
|
port: parseInt(process.env.SMTP_PORT ?? '587'),
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER ?? 'noreply@bshtech.net',
|
||||||
|
pass: process.env.SMTP_PASS ?? 'Ev3ntifyN0Reply2026',
|
||||||
|
},
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Public API ───
|
||||||
|
|
||||||
|
export function getUsers(): MonitorUser[] {
|
||||||
|
return loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUser(email: string, name: string, role: UserRole, notifyOnError = true): MonitorUser {
|
||||||
|
const users = loadUsers();
|
||||||
|
if (users.find(u => u.email === email)) {
|
||||||
|
throw new Error(`User with email ${email} already exists`);
|
||||||
|
}
|
||||||
|
const user: MonitorUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
notifyOnError,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
users.push(user);
|
||||||
|
saveUsers(users);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: string, updates: Partial<Pick<MonitorUser, 'name' | 'role' | 'notifyOnError'>>): MonitorUser {
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.id === id);
|
||||||
|
if (idx < 0) throw new Error('User not found');
|
||||||
|
users[idx] = { ...users[idx], ...updates };
|
||||||
|
saveUsers(users);
|
||||||
|
return users[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: string): void {
|
||||||
|
const users = loadUsers();
|
||||||
|
const filtered = users.filter(u => u.id !== id);
|
||||||
|
if (filtered.length === users.length) throw new Error('User not found');
|
||||||
|
saveUsers(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotifications(limit = 50): Notification[] {
|
||||||
|
return loadNotifications().reverse().slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNotificationRead(id: string): void {
|
||||||
|
const notifs = loadNotifications();
|
||||||
|
const n = notifs.find(n => n.id === id);
|
||||||
|
if (n) {
|
||||||
|
n.read = true;
|
||||||
|
saveNotifications(notifs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllRead(): void {
|
||||||
|
const notifs = loadNotifications();
|
||||||
|
notifs.forEach(n => n.read = true);
|
||||||
|
saveNotifications(notifs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadCount(): number {
|
||||||
|
return loadNotifications().filter(n => !n.read).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNotification(
|
||||||
|
type: Notification['type'],
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
source: string,
|
||||||
|
): Promise<Notification> {
|
||||||
|
const notif: Notification = {
|
||||||
|
id: randomUUID(),
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
read: false,
|
||||||
|
emailSent: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const notifs = loadNotifications();
|
||||||
|
notifs.push(notif);
|
||||||
|
saveNotifications(notifs);
|
||||||
|
|
||||||
|
// Send email to subscribed users
|
||||||
|
if (type === 'error' || type === 'warning') {
|
||||||
|
const users = loadUsers().filter(u => u.notifyOnError);
|
||||||
|
if (users.length > 0) {
|
||||||
|
try {
|
||||||
|
await smtpTransport.sendMail({
|
||||||
|
from: '"Eventify Monitor" <noreply@bshtech.net>',
|
||||||
|
to: users.map(u => u.email).join(', '),
|
||||||
|
subject: `[${type.toUpperCase()}] ${title}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<div style="background: ${type === 'error' ? '#dc2626' : '#f59e0b'}; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
|
||||||
|
<h2 style="margin: 0; font-size: 18px;">${type === 'error' ? 'Error Alert' : 'Warning'}: ${title}</h2>
|
||||||
|
</div>
|
||||||
|
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||||
|
<p style="margin: 0 0 12px; color: #374151;">${message}</p>
|
||||||
|
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px;"><strong>Source:</strong> ${source}</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 13px;"><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;">
|
||||||
|
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
<a href="https://status.eventifyplus.com" style="color: #2563eb;">View Dashboard</a> | Eventify Server Monitor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
notif.emailSent = true;
|
||||||
|
// Update the saved notification
|
||||||
|
const updated = loadNotifications();
|
||||||
|
const idx = updated.findIndex(n => n.id === notif.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
updated[idx].emailSent = true;
|
||||||
|
saveNotifications(updated);
|
||||||
|
}
|
||||||
|
console.log(`[Notifications] Email sent to ${users.length} users for: ${title}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Notifications] Failed to send email:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notif;
|
||||||
|
}
|
||||||
@@ -45,6 +45,30 @@ export interface NginxStatus {
|
|||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── User & Notification Types ───
|
||||||
|
|
||||||
|
export type UserRole = 'developer' | 'server-admin';
|
||||||
|
|
||||||
|
export interface MonitorUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
notifyOnError: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: 'error' | 'warning' | 'info' | 'recovery';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
source: string;
|
||||||
|
timestamp: string;
|
||||||
|
read: boolean;
|
||||||
|
emailSent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OverviewResponse {
|
export interface OverviewResponse {
|
||||||
system: SystemInfo | null;
|
system: SystemInfo | null;
|
||||||
memory: MemoryInfo | null;
|
memory: MemoryInfo | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user