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 { StoragePage } from "@/components/pages/StoragePage"
|
||||
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 { useServerHealth } from "@/hooks/useServerHealth"
|
||||
import { useCpuLive } from "@/hooks/useCpuLive"
|
||||
@@ -48,6 +50,8 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
memory: "Memory",
|
||||
storage: "Storage",
|
||||
network: "Network",
|
||||
settings: "Settings",
|
||||
notifications: "Notifications",
|
||||
}
|
||||
|
||||
function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
|
||||
@@ -145,6 +149,10 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
|
||||
return <StoragePage disk={disk} />
|
||||
case "network":
|
||||
return <NetworkPage containers={containers} nginx={nginx} />
|
||||
case "settings":
|
||||
return <SettingsPage />
|
||||
case "notifications":
|
||||
return <NotificationsPage />
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
Bell,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Server,
|
||||
@@ -24,6 +25,7 @@ const mainNav: NavItem[] = [
|
||||
{ id: "memory", label: "Memory", icon: MemoryStick },
|
||||
{ id: "storage", label: "Storage", icon: HardDrive },
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user