diff --git a/.npmrc b/.npmrc index 2810e35..fb8721e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ -@modl-gg:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} \ No newline at end of file +@modl-gg:registry=https://nexus.modl.gg/repository/npm-releases/ diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c355d6..da92bb7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,6 +10,7 @@ import MonitoringPage from '@/pages/MonitoringPage'; import LoadingPage from '@/pages/LoadingPage'; import AnalyticsPage from '@/pages/AnalyticsPage'; import SystemPromptsPage from '@/pages/SystemPromptsPage'; +import AlertsPage from '@/pages/AlertsPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -46,6 +47,7 @@ function AppRoutes() { + diff --git a/client/src/components/layout/Layout.tsx b/client/src/components/layout/Layout.tsx index 202003a..c832893 100644 --- a/client/src/components/layout/Layout.tsx +++ b/client/src/components/layout/Layout.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'wouter'; -import { LayoutDashboard, Server, Activity, BarChart3, Sparkles, LogOut } from 'lucide-react'; +import { LayoutDashboard, Server, Activity, BarChart3, Sparkles, Bell, LogOut } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { useAuth } from '@/hooks/useAuth'; import Logo from '@/components/Logo'; @@ -10,6 +10,7 @@ const navItems = [ { href: '/monitoring', label: 'Monitoring', icon: Activity }, { href: '/analytics', label: 'Analytics', icon: BarChart3 }, { href: '/prompts', label: 'AI Prompts', icon: Sparkles }, + { href: '/alerts', label: 'Alerts', icon: Bell }, ]; export default function Layout({ children }: { children: React.ReactNode }) { diff --git a/client/src/hooks/useAuth.tsx b/client/src/hooks/useAuth.tsx index 65fda9b..3f77114 100644 --- a/client/src/hooks/useAuth.tsx +++ b/client/src/hooks/useAuth.tsx @@ -25,7 +25,7 @@ export function useAuth() { throw caught; } }, - staleTime: 10 * 60 * 1000, + staleTime: 60 * 1000, retry: false, }); diff --git a/client/src/lib/api-contracts/common.ts b/client/src/lib/api-contracts/common.ts index 20f9a72..93c25c4 100644 --- a/client/src/lib/api-contracts/common.ts +++ b/client/src/lib/api-contracts/common.ts @@ -87,6 +87,26 @@ export function normalizeDateValue(value: unknown): string | undefined { return parsed.toISOString(); } +export function normalizeEpochMillisValue(value: unknown): string | undefined { + let millis: number | undefined; + + if (typeof value === 'number' && Number.isFinite(value)) { + millis = value; + } else if (typeof value === 'string' && /^-?\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + millis = parsed; + } + } + + if (millis === undefined || millis <= 0) { + return undefined; + } + + const date = new Date(millis); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + export function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 136b03a..fce5f21 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -497,12 +497,6 @@ class ApiClient { return this.request(`/security/events${query ? `?${query}` : ''}`); } - async testSecurityConfig() { - return this.request('/security/test', { - method: 'POST', - }); - } - async getRateLimitStatus() { return this.request('/system/rate-limits'); } diff --git a/client/src/lib/services/alerts-service.ts b/client/src/lib/services/alerts-service.ts new file mode 100644 index 0000000..6172a9b --- /dev/null +++ b/client/src/lib/services/alerts-service.ts @@ -0,0 +1,96 @@ +import { requestJsonRaw } from '@/lib/api'; +import { isRecord, normalizeEpochMillisValue, toEpochMillisString } from '@/lib/api-contracts/common'; + +export type SystemAlertSeverity = 'BASIC' | 'WARNING' | 'CRITICAL'; +export type SystemAlertAudience = 'ALL_PANEL_USERS' | 'SUPER_ADMINS_ONLY'; + +export interface SystemAlert { + id: string; + message: string; + severity: SystemAlertSeverity; + audience: SystemAlertAudience; + expiresAt?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; + updatedBy?: string; +} + +interface RawSystemAlert { + id?: unknown; + _id?: unknown; + message?: unknown; + severity?: unknown; + audience?: unknown; + expiresAt?: unknown; + createdAt?: unknown; + updatedAt?: unknown; + createdBy?: unknown; + updatedBy?: unknown; +} + +export interface AlertPayload { + message: string; + severity: SystemAlertSeverity; + audience: SystemAlertAudience; + expiresAt: string; +} + +function toSeverity(value: unknown): SystemAlertSeverity { + const normalized = typeof value === 'string' ? value.trim().toUpperCase() : ''; + return normalized === 'WARNING' || normalized === 'CRITICAL' ? normalized : 'BASIC'; +} + +function toAudience(value: unknown): SystemAlertAudience { + const normalized = typeof value === 'string' ? value.trim().toUpperCase() : ''; + return normalized === 'SUPER_ADMINS_ONLY' ? normalized : 'ALL_PANEL_USERS'; +} + +function mapAlert(raw: RawSystemAlert): SystemAlert { + const id = typeof raw.id === 'string' ? raw.id : (typeof raw._id === 'string' ? raw._id : ''); + + return { + id, + message: typeof raw.message === 'string' ? raw.message : '', + severity: toSeverity(raw.severity), + audience: toAudience(raw.audience), + expiresAt: normalizeEpochMillisValue(raw.expiresAt), + createdAt: normalizeEpochMillisValue(raw.createdAt), + updatedAt: normalizeEpochMillisValue(raw.updatedAt), + createdBy: typeof raw.createdBy === 'string' ? raw.createdBy : undefined, + updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : undefined, + }; +} + +function toRequestBody(payload: AlertPayload): Record { + return { + message: payload.message, + severity: payload.severity, + audience: payload.audience, + expiresAt: toEpochMillisString(payload.expiresAt) ?? '0', + }; +} + +export const alertsService = { + async getAlerts(): Promise { + const raw = await requestJsonRaw('/v1/admin/alerts'); + const items = isRecord(raw) && Array.isArray(raw.items) ? (raw.items as RawSystemAlert[]) : []; + return items.map(mapAlert); + }, + + async createAlert(payload: AlertPayload): Promise { + const raw = await requestJsonRaw('/v1/admin/alerts', { + method: 'POST', + body: toRequestBody(payload), + }); + return mapAlert(raw); + }, + + async updateAlert(id: string, payload: AlertPayload): Promise { + const raw = await requestJsonRaw(`/v1/admin/alerts/${id}`, { + method: 'PUT', + body: toRequestBody(payload), + }); + return mapAlert(raw); + }, +}; diff --git a/client/src/lib/services/analytics-service.ts b/client/src/lib/services/analytics-service.ts index ec00974..62d2076 100644 --- a/client/src/lib/services/analytics-service.ts +++ b/client/src/lib/services/analytics-service.ts @@ -1,5 +1,5 @@ import { requestJsonRaw, requestText } from '@/lib/api'; -import { parseNumber, isRecord, unwrapEnvelope } from '@/lib/api-contracts/common'; +import { isRecord, unwrapEnvelope } from '@/lib/api-contracts/common'; export type AnalyticsRange = '7d' | '30d' | '90d' | '1y'; export type AnalyticsExportType = 'csv' | 'json'; diff --git a/client/src/lib/services/auth-service.ts b/client/src/lib/services/auth-service.ts index 1d28035..ed8f0a3 100644 --- a/client/src/lib/services/auth-service.ts +++ b/client/src/lib/services/auth-service.ts @@ -23,6 +23,7 @@ interface SessionPayload { interface LoginPayload { email?: unknown; lastActivityAt?: unknown; + isAuthenticated?: boolean; } function mapSessionPayload(payload: SessionPayload): AdminSession { @@ -63,7 +64,7 @@ export const authService = { email: typeof data.email === 'string' ? data.email : email, lastActivityAt: normalizeDateValue(data.lastActivityAt), loggedInIps: [], - isAuthenticated: true, + isAuthenticated: data.isAuthenticated ?? true, }; }, diff --git a/client/src/lib/services/servers-service.ts b/client/src/lib/services/servers-service.ts index fbc9e6a..21a0397 100644 --- a/client/src/lib/services/servers-service.ts +++ b/client/src/lib/services/servers-service.ts @@ -196,6 +196,21 @@ function normalizeSubscriptionStatus(value: unknown): SubscriptionStatus | undef : undefined; } +function toOptionalCount(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && /^-?\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return undefined; +} + function ensureServerId(value: RawServer): string { if (typeof value.id === 'string') { return value.id; @@ -219,8 +234,8 @@ function mapServerListItem(raw: RawServer): AdminServerListItem { provisioningStatus: normalizeProvisioningStatus(raw.provisioningStatus), createdAt: normalizeDateValue(raw.createdAt), updatedAt: normalizeDateValue(raw.updatedAt), - userCount: typeof raw.userCount === 'number' ? raw.userCount : undefined, - ticketCount: typeof raw.ticketCount === 'number' ? raw.ticketCount : undefined, + userCount: toOptionalCount(raw.userCount), + ticketCount: toOptionalCount(raw.ticketCount), lastActivityAt: normalizeDateValue(raw.lastActivityAt), }; } diff --git a/client/src/lib/services/system-service.ts b/client/src/lib/services/system-service.ts index bf96f09..a520d3c 100644 --- a/client/src/lib/services/system-service.ts +++ b/client/src/lib/services/system-service.ts @@ -56,11 +56,8 @@ export interface MaintenanceStatus { message: string; } -export type PromptStrictnessLevel = 'lenient' | 'standard' | 'strict'; - export interface SystemPrompt { id: string; - strictnessLevel: PromptStrictnessLevel; prompt: string; isActive: boolean; createdAt?: string; @@ -70,32 +67,17 @@ export interface SystemPrompt { interface RawSystemPrompt { id?: unknown; _id?: unknown; - strictnessLevel?: unknown; prompt?: unknown; isActive?: unknown; createdAt?: unknown; updatedAt?: unknown; } -function toPromptStrictness(value: unknown): PromptStrictnessLevel { - if (typeof value !== 'string') { - return 'standard'; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === 'lenient' || normalized === 'strict') { - return normalized; - } - - return 'standard'; -} - function mapPrompt(raw: RawSystemPrompt): SystemPrompt { const id = typeof raw.id === 'string' ? raw.id : (typeof raw._id === 'string' ? raw._id : ''); return { id, - strictnessLevel: toPromptStrictness(raw.strictnessLevel), prompt: typeof raw.prompt === 'string' ? raw.prompt : '', isActive: raw.isActive !== false, createdAt: normalizeDateValue(raw.createdAt), @@ -103,10 +85,6 @@ function mapPrompt(raw: RawSystemPrompt): SystemPrompt { }; } -function toUpperStrictness(level: PromptStrictnessLevel): string { - return level.toUpperCase(); -} - export const systemService = { async getSystemConfig(): Promise { const raw = await requestJsonRaw('/v1/admin/system/config'); @@ -149,16 +127,14 @@ export const systemService = { return message ?? 'Service restart requested'; }, - async getSystemPrompts(): Promise { + async getSystemPrompt(): Promise { const raw = await requestJsonRaw('/v1/admin/system/prompts'); - const { data } = unwrapEnvelope(raw, 'admin system prompts'); - - const prompts = Array.isArray(data) ? (data as RawSystemPrompt[]) : []; - return prompts.map(mapPrompt); + const { data } = unwrapEnvelope(raw, 'admin system prompt'); + return mapPrompt(data); }, - async updateSystemPrompt(strictnessLevel: PromptStrictnessLevel, prompt: string): Promise { - const raw = await requestJsonRaw(`/v1/admin/system/prompts/${toUpperStrictness(strictnessLevel)}`, { + async updateSystemPrompt(prompt: string): Promise { + const raw = await requestJsonRaw('/v1/admin/system/prompts', { method: 'PUT', body: { prompt }, }); @@ -167,8 +143,8 @@ export const systemService = { return mapPrompt(data); }, - async resetSystemPrompt(strictnessLevel: PromptStrictnessLevel): Promise { - const raw = await requestJsonRaw(`/v1/admin/system/prompts/${toUpperStrictness(strictnessLevel)}/reset`, { + async resetSystemPrompt(): Promise { + const raw = await requestJsonRaw('/v1/admin/system/prompts/reset', { method: 'POST', }); diff --git a/client/src/pages/AlertsPage.tsx b/client/src/pages/AlertsPage.tsx new file mode 100644 index 0000000..4d8a60b --- /dev/null +++ b/client/src/pages/AlertsPage.tsx @@ -0,0 +1,358 @@ +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { Badge } from '@modl-gg/shared-web/components/ui/badge'; +import { Button } from '@modl-gg/shared-web/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; +import { Textarea } from '@modl-gg/shared-web/components/ui/textarea'; +import { Input } from '@modl-gg/shared-web/components/ui/input'; +import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; +import { AlertTriangle, Bell, Edit, RefreshCw, Save, X } from 'lucide-react'; +import { + alertsService, + type AlertPayload, + type SystemAlert, + type SystemAlertAudience, + type SystemAlertSeverity, +} from '@/lib/services/alerts-service'; + +const severityLabels: Record = { + BASIC: 'Basic', + WARNING: 'Warning', + CRITICAL: 'Critical', +}; + +const audienceLabels: Record = { + ALL_PANEL_USERS: 'All panel users', + SUPER_ADMINS_ONLY: 'Super admins only', +}; + +interface AlertFormState { + message: string; + severity: SystemAlertSeverity; + audience: SystemAlertAudience; + expiresAt: string; +} + +function toDatetimeLocal(date: Date): string { + const offsetMs = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} + +function defaultFormState(): AlertFormState { + return { + message: '', + severity: 'BASIC', + audience: 'ALL_PANEL_USERS', + expiresAt: toDatetimeLocal(new Date(Date.now() + 24 * 60 * 60 * 1000)), + }; +} + +function toFormState(alert: SystemAlert): AlertFormState { + return { + message: alert.message, + severity: alert.severity, + audience: alert.audience, + expiresAt: alert.expiresAt ? toDatetimeLocal(new Date(alert.expiresAt)) : defaultFormState().expiresAt, + }; +} + +function toPayload(form: AlertFormState): AlertPayload { + return { + message: form.message.trim(), + severity: form.severity, + audience: form.audience, + expiresAt: new Date(form.expiresAt).toISOString(), + }; +} + +function formatDate(value?: string): string { + if (!value) return 'Unknown'; + return new Date(value).toLocaleString(); +} + +export default function AlertsPage() { + const [alerts, setAlerts] = useState([]); + const [form, setForm] = useState(defaultFormState); + const [editingId, setEditingId] = useState(null); + const [minimumExpiresAt, setMinimumExpiresAt] = useState(() => toDatetimeLocal(new Date())); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const loadSequence = useRef(0); + const { toast } = useToast(); + + const editingAlert = useMemo( + () => alerts.find((alert) => alert.id === editingId) ?? null, + [alerts, editingId] + ); + + useEffect(() => { + loadAlerts(); + }, []); + + useEffect(() => { + const updateMinimumExpiresAt = () => setMinimumExpiresAt(toDatetimeLocal(new Date())); + const interval = window.setInterval(updateMinimumExpiresAt, 30_000); + + return () => window.clearInterval(interval); + }, []); + + useEffect(() => { + if (!editingId) { + return; + } + + if (!alerts.some((alert) => alert.id === editingId)) { + resetForm(); + } + }, [alerts, editingId]); + + const loadAlerts = async () => { + const requestId = loadSequence.current + 1; + loadSequence.current = requestId; + + try { + setLoading(true); + const loadedAlerts = await alertsService.getAlerts(); + if (loadSequence.current === requestId) { + setAlerts(loadedAlerts); + } + } catch (caught) { + if (loadSequence.current !== requestId) { + return; + } + console.error('Error loading alerts:', caught); + toast({ + title: 'Error', + description: 'Failed to load alerts', + variant: 'destructive', + }); + } finally { + if (loadSequence.current === requestId) { + setLoading(false); + } + } + }; + + const resetForm = () => { + setEditingId(null); + setForm(defaultFormState()); + }; + + const startEditing = (alert: SystemAlert) => { + setEditingId(alert.id); + setForm(toFormState(alert)); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const expiresAtMs = new Date(form.expiresAt).getTime(); + const now = Date.now(); + setMinimumExpiresAt(toDatetimeLocal(new Date(now))); + + if (!form.message.trim()) { + toast({ + title: 'Error', + description: 'Alert message is required', + variant: 'destructive', + }); + return; + } + + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) { + toast({ + title: 'Error', + description: 'Expiry must be in the future', + variant: 'destructive', + }); + return; + } + + if (editingId && !editingAlert) { + resetForm(); + toast({ + title: 'Error', + description: 'The alert being edited is no longer available', + variant: 'destructive', + }); + return; + } + + try { + setSaving(true); + if (editingId) { + await alertsService.updateAlert(editingId, toPayload(form)); + } else { + await alertsService.createAlert(toPayload(form)); + } + toast({ + title: 'Success', + description: editingId ? 'Alert updated' : 'Alert created', + }); + resetForm(); + await loadAlerts(); + } catch (caught) { + console.error('Error saving alert:', caught); + toast({ + title: 'Error', + description: 'Failed to save alert', + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const isExpired = (alert: SystemAlert) => alert.expiresAt ? new Date(alert.expiresAt).getTime() <= Date.now() : true; + + return ( +
+
+
+
+

Panel Alerts

+

Create dashboard notices for server panel users.

+
+ +
+ +
+ + + + + {editingAlert ? 'Edit Alert' : 'New Alert'} + + + Alerts remain stored after expiry and stop showing automatically. + + + +
+
+ +