261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|
import AdminShell from '../ui/AdminShell'
|
|
|
|
type ServiceState = {
|
|
name: string
|
|
status: string
|
|
message?: string
|
|
}
|
|
|
|
type RecentRequest = {
|
|
id: number
|
|
title?: string | null
|
|
year?: number | null
|
|
statusLabel?: string | null
|
|
requestedBy?: string | null
|
|
createdAt?: string | null
|
|
}
|
|
|
|
type PortalOverview = {
|
|
overview?: {
|
|
total_items?: number
|
|
total_comments?: number
|
|
by_kind?: Record<string, number>
|
|
by_status?: Record<string, number>
|
|
}
|
|
my_items?: number
|
|
}
|
|
|
|
const formatDateTime = (value?: string | null) => {
|
|
if (!value) return 'Unknown'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
const normalizeRecent = (items: any[]): RecentRequest[] =>
|
|
items
|
|
.filter((item) => item?.id)
|
|
.map((item) => ({
|
|
id: Number(item.id),
|
|
title: item.title ?? null,
|
|
year: item.year ?? null,
|
|
statusLabel: item.statusLabel ?? null,
|
|
requestedBy: item.requestedBy ?? null,
|
|
createdAt: item.createdAt ?? null,
|
|
}))
|
|
|
|
export default function AdminLandingPage() {
|
|
const router = useRouter()
|
|
const [services, setServices] = useState<ServiceState[]>([])
|
|
const [serviceOverall, setServiceOverall] = useState('unknown')
|
|
const [recent, setRecent] = useState<RecentRequest[]>([])
|
|
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
const load = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
|
|
authFetch(`${baseUrl}/auth/me`),
|
|
authFetch(`${baseUrl}/status/services`),
|
|
authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
|
|
authFetch(`${baseUrl}/portal/overview`),
|
|
])
|
|
|
|
if (meResponse.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (meResponse.status === 403) {
|
|
router.push('/')
|
|
return
|
|
}
|
|
const me = await meResponse.json()
|
|
if (me?.role !== 'admin') {
|
|
router.push('/')
|
|
return
|
|
}
|
|
|
|
if (serviceResponse.ok) {
|
|
const data = await serviceResponse.json()
|
|
setServiceOverall(data?.overall ?? 'unknown')
|
|
setServices(Array.isArray(data?.services) ? data.services : [])
|
|
}
|
|
|
|
if (recentResponse.ok) {
|
|
const data = await recentResponse.json()
|
|
setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
|
|
}
|
|
|
|
if (overviewResponse.ok) {
|
|
const data = await overviewResponse.json()
|
|
setPortalOverview(data)
|
|
}
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Unable to load the operations dashboard.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
}, [router])
|
|
|
|
const serviceCounts = useMemo(() => {
|
|
const up = services.filter((service) => service.status === 'up').length
|
|
const down = services.filter((service) => service.status === 'down').length
|
|
const degraded = services.filter((service) => service.status === 'degraded').length
|
|
const notConfigured = services.filter((service) => service.status === 'not_configured').length
|
|
return { up, down, degraded, notConfigured, total: services.length }
|
|
}, [services])
|
|
|
|
const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
|
|
const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
|
|
const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
|
|
|
|
const rail = (
|
|
<div className="admin-rail-stack">
|
|
<div className="admin-rail-card">
|
|
<span className="admin-rail-eyebrow">Service ecosystem</span>
|
|
<div className="service-ecosystem">
|
|
{services.length === 0 ? (
|
|
<div className="status-banner">Service status is not available yet.</div>
|
|
) : (
|
|
services.map((service) => (
|
|
<a
|
|
key={service.name}
|
|
className="service-row"
|
|
href={`/admin/${service.name.toLowerCase().replace(/[^a-z0-9]/g, '')}`}
|
|
>
|
|
<span className={`system-dot system-dot-${service.status}`} />
|
|
<span>
|
|
<strong>{service.name}</strong>
|
|
<small>{service.message ?? 'No message reported'}</small>
|
|
</span>
|
|
<span className={`small-pill system-pill-${service.status}`}>{service.status}</span>
|
|
</a>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="admin-rail-card">
|
|
<span className="admin-rail-eyebrow">Quick actions</span>
|
|
<div className="quick-action-grid">
|
|
<a href="/admin/requests-all">Review requests</a>
|
|
<a href="/admin/issues">Manage issues</a>
|
|
<a href="/users">User directory</a>
|
|
<a href="/admin/logs">Activity log</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<AdminShell
|
|
title="Operations Center"
|
|
subtitle="Live Magent controls, request movement, issue intake, and service health."
|
|
rail={rail}
|
|
actions={
|
|
<button type="button" onClick={() => router.push('/')}>
|
|
View health
|
|
</button>
|
|
}
|
|
>
|
|
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
|
{error ? <div className="error-banner">{error}</div> : null}
|
|
|
|
<section className="ops-metric-grid">
|
|
<div className="ops-metric-card">
|
|
<span className="section-kicker">Services online</span>
|
|
<strong>
|
|
{serviceCounts.up}/{serviceCounts.total || 0}
|
|
</strong>
|
|
<p>{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}</p>
|
|
</div>
|
|
<div className="ops-metric-card">
|
|
<span className="section-kicker">Recent requests</span>
|
|
<strong>{recent.length}</strong>
|
|
<p>Loaded from the live request cache.</p>
|
|
</div>
|
|
<div className="ops-metric-card">
|
|
<span className="section-kicker">Open issue items</span>
|
|
<strong>{issueCount}</strong>
|
|
<p>{commentCount} portal comments recorded.</p>
|
|
</div>
|
|
<div className="ops-metric-card">
|
|
<span className="section-kicker">Portal requests</span>
|
|
<strong>{requestItemCount}</strong>
|
|
<p>Tracked in the dedicated request workflow.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="admin-zone">
|
|
<div className="section-header">
|
|
<div>
|
|
<h2>Recent activity</h2>
|
|
<p className="section-subtitle">Live request cache entries, newest first.</p>
|
|
</div>
|
|
</div>
|
|
{recent.length === 0 ? (
|
|
<div className="status-banner">No recent requests were returned.</div>
|
|
) : (
|
|
<div className="admin-table dashboard-activity-table">
|
|
<div className="admin-table-head">
|
|
<span>Request</span>
|
|
<span>Status</span>
|
|
<span>User</span>
|
|
<span>Created</span>
|
|
</div>
|
|
{recent.map((row) => (
|
|
<button
|
|
key={row.id}
|
|
type="button"
|
|
className="admin-table-row"
|
|
onClick={() => router.push(`/requests/${row.id}`)}
|
|
>
|
|
<span>
|
|
{row.title || `Request #${row.id}`}
|
|
{row.year ? ` (${row.year})` : ''}
|
|
</span>
|
|
<span>{row.statusLabel || 'Unknown'}</span>
|
|
<span>{row.requestedBy || 'Unknown'}</span>
|
|
<span>{formatDateTime(row.createdAt)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="admin-zone">
|
|
<div className="section-header">
|
|
<div>
|
|
<h2>Attention states</h2>
|
|
<p className="section-subtitle">Service states that affect request processing.</p>
|
|
</div>
|
|
</div>
|
|
<div className="ops-status-strip">
|
|
<span>{serviceCounts.down} down</span>
|
|
<span>{serviceCounts.degraded} degraded</span>
|
|
<span>{serviceCounts.notConfigured} not configured</span>
|
|
</div>
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|