Version 0.4

This commit is contained in:
2026-04-16 23:08:23 +01:00
parent e011afbff1
commit 39eb818e7e
110 changed files with 18905 additions and 14 deletions

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "arcane-status-frontend",
"version": "0.4.0",
"private": true,
"proxy": "http://localhost:5000",
"dependencies": {
"axios": "^1.15.0",
"lucide-react": "^1.8.0",
"date-fns": "^4.1.0",
"markdown-to-jsx": "^9.7.15",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"react-scripts": "5.0.1",
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Status Page - Monitor your services"
/>
<title>Status Page</title>
<style>
/* Initial loading styles - shown before React loads */
#initial-loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: #0f0f1e;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
z-index: 9999;
opacity: 1;
transition: opacity 0.3s ease;
}
#initial-loader.hidden {
opacity: 0;
pointer-events: none;
}
.initial-spinner {
width: 48px;
height: 48px;
position: relative;
}
.initial-spinner div {
box-sizing: border-box;
display: block;
position: absolute;
width: 100%;
height: 100%;
border: 3px solid #6366f1;
border-radius: 50%;
animation: initial-spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #6366f1 transparent transparent transparent;
}
.initial-spinner div:nth-child(1) { animation-delay: -0.45s; }
.initial-spinner div:nth-child(2) { animation-delay: -0.3s; }
.initial-spinner div:nth-child(3) { animation-delay: -0.15s; }
@keyframes initial-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.initial-loader-text {
color: #cbd5e1;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
font-size: 14px;
font-weight: 500;
margin: 0;
animation: initial-pulse 1.5s ease-in-out infinite;
}
@keyframes initial-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="initial-loader">
<div class="initial-spinner">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<p class="initial-loader-text">Loading Status Page...</p>
</div>
<div id="root"></div>
</body>
</html>

148
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import io from 'socket.io-client';
import client from './shared/api/client';
import { checkSetup, getProfile, logout } from './shared/api/authApi';
import { LoadingSpinner } from './shared/components';
import AppProviders from './app/AppProviders';
import AppRoutes from './app/routes';
import './styles/base/App.css';
function AppContent() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [setupComplete, setSetupComplete] = useState(null);
const [socket, setSocket] = useState(null);
const [loading, setLoading] = useState(true);
const forceLogout = React.useCallback(() => {
localStorage.removeItem('token');
setIsAuthenticated(false);
}, []);
// Global interceptor any 401/403 kicks the user back to login
useEffect(() => {
const interceptorId = client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 || error.response?.status === 403) {
forceLogout();
}
return Promise.reject(error);
}
);
return () => client.interceptors.response.eject(interceptorId);
}, [forceLogout]);
useEffect(() => {
// Check if setup is complete and validate any stored token
const loadSetupStatus = async () => {
try {
const response = await checkSetup();
// If we get a 200 response, setup is required
if (response.status === 200) {
setSetupComplete(false);
}
} catch (err) {
if (err.response?.status === 400) {
// 400 means setup is already complete
setSetupComplete(true);
} else {
// Assume setup is complete if we can't reach the endpoint
setSetupComplete(true);
}
} finally {
setLoading(false);
}
};
// Validate the stored token against the server before trusting it
const validateToken = async () => {
const token = localStorage.getItem('token');
if (!token) return;
try {
await getProfile();
setIsAuthenticated(true);
} catch {
// Token is expired, revoked, or invalid, clear it
localStorage.removeItem('token');
setIsAuthenticated(false);
}
};
loadSetupStatus();
validateToken();
// Initialize WebSocket connection in production the frontend is served
// from the same origin as the backend, so no URL is needed.
const backendUrl = process.env.REACT_APP_BACKEND_URL || undefined;
const newSocket = io(backendUrl || window.location.origin, {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
});
newSocket.on('connect', () => {
console.log('Connected to server');
});
setSocket(newSocket);
return () => newSocket.close();
}, []);
const handleLogin = (token) => {
localStorage.setItem('token', token);
setIsAuthenticated(true);
};
const handleLogout = async () => {
try {
const token = localStorage.getItem('token');
if (token) {
await logout();
}
} catch (err) {
// Logout even if the API call fails
console.error('Logout API error:', err);
}
forceLogout();
};
const handleSetupComplete = () => {
setSetupComplete(true);
};
if (loading) {
return (
<div className="loading-page">
<LoadingSpinner size="large" text="Initializing status page..." />
</div>
);
}
return (
<Router>
<AppRoutes
socket={socket}
isAuthenticated={isAuthenticated}
setupComplete={setupComplete}
onLogin={handleLogin}
onLogout={handleLogout}
onSetupComplete={handleSetupComplete}
/>
</Router>
);
}
function App() {
return (
<AppProviders>
<AppContent />
</AppProviders>
);
}
export default App;

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { ThemeProvider } from '../context/ThemeContext';
export default function AppProviders({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { StatusPage, EndpointPage } from '../features/status/pages';
import { AdminDashboardPage } from '../features/admin/pages';
import { LoginPage, SetupPage } from '../features/auth/pages';
import { IncidentPage } from '../features/incidents/pages';
export default function AppRoutes({
socket,
isAuthenticated,
setupComplete,
onLogin,
onLogout,
onSetupComplete,
}) {
if (!setupComplete) {
return <SetupPage onSetupComplete={onSetupComplete} />;
}
return (
<Routes>
<Route path="/" element={<StatusPage socket={socket} />} />
<Route path="/endpoint/:endpointId" element={<EndpointPage socket={socket} />} />
<Route path="/incident/:incidentId" element={<IncidentPage />} />
<Route
path="/admin"
element={
isAuthenticated ? (
<AdminDashboardPage socket={socket} onLogout={onLogout} />
) : (
<Navigate to="/login" />
)
}
/>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/admin" /> : <LoginPage onLogin={onLogin} />}
/>
</Routes>
);
}

View File

@@ -0,0 +1,34 @@
import React, { createContext, useState, useEffect } from 'react';
import { getStoredValue } from '../shared/utils/storage';
export const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const saved = getStoredValue('theme', null);
return saved || 'dark';
});
useEffect(() => {
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { LayoutDashboard } from 'lucide-react';
export default function AdminDashboardSkeleton() {
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<div className="admin-sidebar__brand">
<LayoutDashboard size={18} />
<span>Admin</span>
</div>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton skeleton--nav" />
))}
</aside>
<div className="admin-main">
<div className="admin-topbar skeleton-topbar">
<div className="skeleton skeleton--title" />
<div className="skeleton skeleton--btn" />
</div>
<div className="admin-body">
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton skeleton--row" />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,779 @@
import React, { useState, useEffect, useCallback } from 'react';
import Markdown from 'markdown-to-jsx';
import { IncidentTimeline } from '../../incidents/components';
import '../../../styles/features/admin/AdminIncidents.css';
import {
createIncident,
updateIncident,
deleteIncident,
addIncidentUpdate,
resolveIncident,
reopenIncident,
saveIncidentPostMortem,
createMaintenance,
deleteMaintenance,
} from '../../../shared/api/adminApi';
import { getPublicIncidents, getPublicMaintenance } from '../../../shared/api/publicApi';
const SEVERITIES = ['degraded', 'down'];
const STATUSES = ['investigating', 'identified', 'monitoring'];
const STATUS_LABELS = {
investigating: 'Investigating',
identified: 'Identified',
monitoring: 'Monitoring',
resolved: 'Resolved',
};
const SEVERITY_LABELS = { degraded: 'Degraded', down: 'Critical' };
const BLANK_FORM = {
title: '', description: '', severity: 'degraded',
status: 'investigating', endpoint_ids: [], initial_message: '',
};
const BLANK_UPDATE = { message: '', status_label: '' };
const BLANK_MAINT = {
title: '', description: '', endpoint_id: '', start_time: '', end_time: '',
};
function AdminIncidents({ endpoints = [] }) {
const [incidents, setIncidents] = useState([]);
const [maintenance, setMaintenance] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
// Incident list UI
const [filterStatus, setFilterStatus] = useState('all'); // 'all' | 'open' | 'resolved'
const [expandedId, setExpandedId] = useState(null);
// Create / edit form
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(BLANK_FORM);
const [mdPreview, setMdPreview] = useState(false);
// Per-incident update form
const [updateTarget, setUpdateTarget] = useState(null); // incident id
const [updateForm, setUpdateForm] = useState(BLANK_UPDATE);
const [updatePreview,setUpdatePreview]= useState(false);
// Resolve form
const [resolveTarget,setResolveTarget]= useState(null);
const [resolveMsg, setResolveMsg] = useState('');
// Reopen form
const [reopenTarget, setReopenTarget] = useState(null);
const [reopenMsg, setReopenMsg] = useState('');
// Post-mortem form
const [pmTarget, setPmTarget] = useState(null);
const [pmText, setPmText] = useState('');
const [pmPreview, setPmPreview] = useState(false);
// Maintenance
const [showMaint, setShowMaint] = useState(false);
const [maintForm, setMaintForm] = useState(BLANK_MAINT);
// Data
const fetchAll = useCallback(async () => {
try {
const [incRes, maintRes] = await Promise.all([
getPublicIncidents(),
getPublicMaintenance(),
]);
setIncidents(incRes.data);
setMaintenance(maintRes.data);
setError(null);
} catch {
setError('Failed to load incidents');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchAll(); }, [fetchAll]);
// Auto-clear success banner
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(null), 3500);
return () => clearTimeout(t);
}, [success]);
// Helpers
const notify = (msg) => { setSuccess(msg); setError(null); };
const fail = (msg) => { setError(msg); setSuccess(null); };
const closeForm = () => { setShowForm(false); setEditingId(null); setForm(BLANK_FORM); setMdPreview(false); };
// Incident CRUD
async function handleSubmitIncident(e) {
e.preventDefault();
try {
if (editingId) {
await updateIncident(editingId, form);
notify('Incident updated');
} else {
await createIncident({
...form,
created_by: 'admin',
source: 'manual',
});
notify('Incident created');
}
closeForm();
fetchAll();
} catch (err) {
fail(err.response?.data?.error || 'Failed to save incident');
}
}
function openEdit(incident) {
setForm({
title: incident.title,
description: incident.description || '',
severity: incident.severity,
status: incident.status,
endpoint_ids: incident.endpoints?.map(e => e.id) || [],
initial_message: '',
});
setEditingId(incident.id);
setShowForm(true);
setExpandedId(null);
}
async function handleDelete(id) {
if (!window.confirm('Delete this incident? This cannot be undone.')) return;
try {
await deleteIncident(id);
notify('Incident deleted');
fetchAll();
} catch {
fail('Failed to delete incident');
}
}
// Timeline update
async function handleAddUpdate(e) {
e.preventDefault();
try {
await addIncidentUpdate(updateTarget, { ...updateForm, created_by: 'admin' });
notify('Update posted');
setUpdateTarget(null);
setUpdateForm(BLANK_UPDATE);
setUpdatePreview(false);
// Re-fetch the specific incident to get updated timeline
fetchAll();
} catch {
fail('Failed to post update');
}
}
// Resolve
async function handleResolve(e) {
e.preventDefault();
try {
await resolveIncident(resolveTarget, {
message: resolveMsg || 'This incident has been resolved.',
created_by: 'admin',
});
notify('Incident resolved');
setResolveTarget(null);
setResolveMsg('');
fetchAll();
} catch {
fail('Failed to resolve incident');
}
}
// Reopen
async function handleReopen(e) {
e.preventDefault();
try {
await reopenIncident(reopenTarget, {
message: reopenMsg || undefined,
created_by: 'admin',
});
notify('Incident re-opened');
setReopenTarget(null);
setReopenMsg('');
fetchAll();
} catch (err) {
fail(err.response?.data?.error || 'Failed to re-open incident');
}
}
// Post-mortem
async function handleSavePostMortem(e) {
e.preventDefault();
try {
await saveIncidentPostMortem(pmTarget, pmText || null);
notify('Post-mortem saved');
setPmTarget(null);
setPmText('');
setPmPreview(false);
fetchAll();
} catch {
fail('Failed to save post-mortem');
}
}
/**
* Returns { canReopen, daysLeft, label } for a resolved incident.
* Window is 7 days from resolved_at.
*/
function getReopenMeta(incident) {
if (!incident.resolved_at) return { canReopen: false, daysLeft: 0, label: '' };
const WINDOW_DAYS = 7;
const resolvedAt = new Date(incident.resolved_at);
const ageMs = Date.now() - resolvedAt.getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
const daysLeft = Math.max(0, Math.ceil(WINDOW_DAYS - ageDays));
if (daysLeft === 0) {
return { canReopen: false, daysLeft: 0, label: 'Re-open window expired' };
}
return {
canReopen: true,
daysLeft,
label: daysLeft === 1 ? '↺ Re-open (1 day left)' : `↺ Re-open (${daysLeft} days left)`,
};
}
// Maintenance
async function handleCreateMaintenance(e) {
e.preventDefault();
try {
await createMaintenance({
...maintForm,
endpoint_id: maintForm.endpoint_id || null,
});
notify('Maintenance window created');
setMaintForm(BLANK_MAINT);
fetchAll();
} catch {
fail('Failed to create maintenance window');
}
}
async function handleDeleteMaintenance(id) {
if (!window.confirm('Delete this maintenance window?')) return;
try {
await deleteMaintenance(id);
notify('Maintenance window deleted');
fetchAll();
} catch {
fail('Failed to delete maintenance window');
}
}
// Filter
const filtered = incidents.filter(i => {
if (filterStatus === 'open') return !i.resolved_at;
if (filterStatus === 'resolved') return !!i.resolved_at;
return true;
});
const openCount = incidents.filter(i => !i.resolved_at).length;
// Endpoint multi-select toggle
function toggleEndpoint(id) {
setForm(prev => ({
...prev,
endpoint_ids: prev.endpoint_ids.includes(id)
? prev.endpoint_ids.filter(x => x !== id)
: [...prev.endpoint_ids, id],
}));
}
if (loading) return <div className="ai-loading">Loading incidents</div>;
return (
<div className="admin-incidents">
{error && <div className="ai-banner ai-error">{error}</div>}
{success && <div className="ai-banner ai-success">{success}</div>}
{/* Toolbar */}
<div className="ai-toolbar">
<div className="ai-filters">
{['all', 'open', 'resolved'].map(f => (
<button
key={f}
className={`ai-filter-btn ${filterStatus === f ? 'active' : ''}`}
onClick={() => setFilterStatus(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
{f === 'open' && openCount > 0 && (
<span className="ai-count-badge">{openCount}</span>
)}
</button>
))}
</div>
<button
className="btn-primary"
onClick={() => { if (showForm) closeForm(); else setShowForm(true); }}
>
{showForm ? 'Cancel' : '+ New Incident'}
</button>
</div>
{/* Create / Edit form */}
{showForm && (
<div className="ai-form-card card">
<h3>{editingId ? 'Edit Incident' : 'Create Incident'}</h3>
<form onSubmit={handleSubmitIncident}>
<div className="form-grid ai-form-grid">
<div className="form-group">
<label>Title *</label>
<input
type="text"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="Brief description of the issue"
required
/>
</div>
<div className="form-group">
<label>Severity</label>
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })}>
{SEVERITIES.map(s => (
<option key={s} value={s}>{SEVERITY_LABELS[s]}</option>
))}
</select>
</div>
<div className="form-group">
<label>Status</label>
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
{STATUSES.map(s => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div className="form-group ai-span-2">
<label>Affected Services</label>
<div className="ai-endpoint-select">
{endpoints.length === 0 && (
<span className="ai-muted">No endpoints configured</span>
)}
{endpoints.map(ep => (
<label key={ep.id} className={`ai-ep-chip ${form.endpoint_ids.includes(ep.id) ? 'selected' : ''}`}>
<input
type="checkbox"
checked={form.endpoint_ids.includes(ep.id)}
onChange={() => toggleEndpoint(ep.id)}
/>
{ep.name}
</label>
))}
</div>
</div>
</div>
{/* Initial message with markdown preview */}
{!editingId && (
<div className="form-group">
<div className="ai-label-row">
<label>Initial Message <span className="ai-muted">(markdown supported)</span></label>
<button type="button" className="ai-preview-toggle" onClick={() => setMdPreview(v => !v)}>
{mdPreview ? 'Edit' : 'Preview'}
</button>
</div>
{mdPreview ? (
<div className="ai-md-preview">
{form.initial_message
? <Markdown options={{ disableParsingRawHTML: true }}>{form.initial_message}</Markdown>
: <span className="ai-muted">Nothing to preview yet</span>
}
</div>
) : (
<textarea
className="ai-textarea"
rows={4}
value={form.initial_message}
onChange={e => setForm({ ...form, initial_message: e.target.value })}
placeholder="Our team has detected an issue with… (markdown supported)"
/>
)}
</div>
)}
<div className="form-footer">
<button type="submit" className="btn-primary">
{editingId ? 'Save Changes' : 'Create Incident'}
</button>
</div>
</form>
</div>
)}
{/* Incident list */}
{filtered.length === 0 ? (
<div className="card ai-empty">
<p>No incidents found</p>
</div>
) : (
<div className="ai-list">
{filtered.map(incident => {
const isOpen = !incident.resolved_at;
const isExpanded = expandedId === incident.id;
const isUpdating = updateTarget === incident.id;
const isResolving= resolveTarget === incident.id;
return (
<div key={incident.id} className={`ai-incident-card ${isOpen ? 'ai-open' : 'ai-resolved'}`}>
{/* Card header */}
<div className="ai-incident-header" onClick={() => setExpandedId(isExpanded ? null : incident.id)}>
<div className="ai-incident-left">
<span className={`ai-sev-dot sev-${incident.severity}`} title={incident.severity} />
<div className="ai-incident-meta">
<div className="ai-incident-title-row">
<span className="ai-incident-title">{incident.title}</span>
<span className={`ai-status-pill pill-${incident.status}`}>
{STATUS_LABELS[incident.status] || incident.status}
</span>
{incident.source === 'auto' && (
<span className="ai-auto-badge">Auto</span>
)}
</div>
<div className="ai-incident-sub">
<span>{new Date(incident.start_time).toLocaleString()}</span>
{incident.resolved_at && (
<span className="ai-resolved-label"> Resolved</span>
)}
{incident.endpoints?.length > 0 && (
<span className="ai-affected">
{incident.endpoints.map(e => e.name).join(', ')}
</span>
)}
</div>
</div>
</div>
<span className="ai-expand-icon">{isExpanded ? '▲' : '▼'}</span>
</div>
{/* Expanded body */}
{isExpanded && (
<div className="ai-incident-body">
{/* Action bar */}
{(() => {
const { canReopen, label: reopenLabel } = getReopenMeta(incident);
const isReopening = reopenTarget === incident.id;
const isPm = pmTarget === incident.id;
return (
<div className="ai-action-bar">
<button className="ai-btn-update" onClick={() => {
setUpdateTarget(isUpdating ? null : incident.id);
setResolveTarget(null);
setReopenTarget(null);
setPmTarget(null);
}}>
{isUpdating ? 'Cancel Update' : '+ Post Update'}
</button>
{isOpen && (
<button className="ai-btn-resolve" onClick={() => {
setResolveTarget(isResolving ? null : incident.id);
setUpdateTarget(null);
setReopenTarget(null);
setPmTarget(null);
}}>
{isResolving ? 'Cancel' : '✓ Resolve'}
</button>
)}
{!isOpen && (
canReopen ? (
<button className="ai-btn-reopen" onClick={() => {
setReopenTarget(isReopening ? null : incident.id);
setUpdateTarget(null);
setResolveTarget(null);
setPmTarget(null);
}}>
{isReopening ? 'Cancel' : reopenLabel}
</button>
) : (
<button className="ai-btn-reopen ai-btn-reopen--expired" disabled title="Re-open window has expired">
Re-open window expired
</button>
)
)}
{!isOpen && (
<button className="ai-btn-postmortem" onClick={() => {
if (isPm) {
setPmTarget(null);
} else {
setPmTarget(incident.id);
setPmText(incident.post_mortem || '');
setPmPreview(false);
}
setUpdateTarget(null);
setResolveTarget(null);
setReopenTarget(null);
}}>
{isPm ? 'Cancel Post-Mortem' : '📝 Post-Mortem'}
</button>
)}
<button className="ai-btn-edit" onClick={() => openEdit(incident)}>Edit</button>
<button className="ai-btn-delete" onClick={() => handleDelete(incident.id)}>Delete</button>
</div>
);
})()}
{/* Add update form */}
{isUpdating && (
<form className="ai-subform" onSubmit={handleAddUpdate}>
<h4>Post Update</h4>
<div className="form-group">
<label>Status Transition <span className="ai-muted">(optional)</span></label>
<select
value={updateForm.status_label}
onChange={e => setUpdateForm({ ...updateForm, status_label: e.target.value })}
>
<option value="">No change</option>
{['investigating','identified','monitoring'].map(s => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div className="form-group">
<div className="ai-label-row">
<label>Message * <span className="ai-muted">(markdown)</span></label>
<button type="button" className="ai-preview-toggle" onClick={() => setUpdatePreview(v => !v)}>
{updatePreview ? 'Edit' : 'Preview'}
</button>
</div>
{updatePreview ? (
<div className="ai-md-preview">
{updateForm.message
? <Markdown options={{ disableParsingRawHTML: true }}>{updateForm.message}</Markdown>
: <span className="ai-muted">Nothing to preview</span>
}
</div>
) : (
<textarea
className="ai-textarea"
rows={3}
value={updateForm.message}
onChange={e => setUpdateForm({ ...updateForm, message: e.target.value })}
placeholder="We have identified the root cause and are working on a fix…"
required
/>
)}
</div>
<button type="submit" className="btn-primary">Post Update</button>
</form>
)}
{/* Resolve form */}
{isResolving && (
<form className="ai-subform ai-resolve-form" onSubmit={handleResolve}>
<h4>Resolve Incident</h4>
<div className="form-group">
<label>Closing Message <span className="ai-muted">(markdown, optional)</span></label>
<textarea
className="ai-textarea"
rows={3}
value={resolveMsg}
onChange={e => setResolveMsg(e.target.value)}
placeholder="This incident has been resolved. The service is operating normally."
/>
</div>
<button type="submit" className="btn-primary ai-resolve-btn">Confirm Resolve</button>
</form>
)}
{/* Reopen form */}
{reopenTarget === incident.id && (
<form className="ai-subform ai-reopen-form" onSubmit={handleReopen}>
<h4>Re-open Incident</h4>
<div className="form-group">
<label>Reason <span className="ai-muted">(optional)</span></label>
<textarea
className="ai-textarea"
rows={2}
value={reopenMsg}
onChange={e => setReopenMsg(e.target.value)}
placeholder="Reason for re-opening this incident…"
/>
</div>
<button type="submit" className="btn-primary">Confirm Re-open</button>
</form>
)}
{/* Post-mortem form */}
{pmTarget === incident.id && (
<form className="ai-subform ai-pm-form" onSubmit={handleSavePostMortem}>
<h4>Post-Mortem</h4>
<div className="form-group">
<div className="ai-label-row">
<label>Note <span className="ai-muted">(markdown supported)</span></label>
<button type="button" className="ai-preview-toggle" onClick={() => setPmPreview(v => !v)}>
{pmPreview ? 'Edit' : 'Preview'}
</button>
</div>
{pmPreview ? (
<div className="ai-md-preview">
{pmText
? <Markdown options={{ disableParsingRawHTML: true }}>{pmText}</Markdown>
: <span className="ai-muted">Nothing to preview yet</span>
}
</div>
) : (
<textarea
className="ai-textarea"
rows={5}
value={pmText}
onChange={e => setPmText(e.target.value)}
placeholder="Root cause, timeline summary, action items…"
/>
)}
</div>
<div className="form-footer">
<button type="submit" className="btn-primary">Save Post-Mortem</button>
{incident.post_mortem && (
<button type="button" className="ai-btn-delete"
onClick={() => { setPmText(''); }}
>Clear</button>
)}
</div>
</form>
)}
{/* Timeline */}
<div className="ai-timeline-section">
<IncidentTimeline
updates={incident.updates || (incident.latest_update ? [incident.latest_update] : [])}
postMortem={incident.post_mortem}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Maintenance Windows */}
<div className="ai-maintenance">
<div
className="ai-maint-header"
onClick={() => setShowMaint(v => !v)}
role="button"
>
<span className="ai-maint-header__title">🔧 Maintenance Windows</span>
<span className="ai-expand-icon">{showMaint ? '▲' : '▼'}</span>
</div>
{showMaint && (
<div className="ai-maint-body">
{/* Create form */}
<form className="ai-subform" onSubmit={handleCreateMaintenance}>
<h4>Schedule Maintenance Window</h4>
<div className="form-grid maint-form-grid">
<div className="form-group maint-title">
<label>Title *</label>
<input
type="text"
value={maintForm.title}
onChange={e => setMaintForm({ ...maintForm, title: e.target.value })}
placeholder="Scheduled database maintenance"
required
/>
</div>
<div className="form-group">
<label>Affected Service</label>
<select
value={maintForm.endpoint_id}
onChange={e => setMaintForm({ ...maintForm, endpoint_id: e.target.value })}
>
<option value="">All services</option>
{endpoints.map(ep => (
<option key={ep.id} value={ep.id}>{ep.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={maintForm.description}
onChange={e => setMaintForm({ ...maintForm, description: e.target.value })}
placeholder="Optional details"
/>
</div>
<div className="form-group">
<label>Start Time *</label>
<input
type="datetime-local"
value={maintForm.start_time}
onChange={e => setMaintForm({ ...maintForm, start_time: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>End Time *</label>
<input
type="datetime-local"
value={maintForm.end_time}
onChange={e => setMaintForm({ ...maintForm, end_time: e.target.value })}
required
/>
</div>
</div>
<div className="form-footer">
<button type="submit" className="btn-primary">Schedule Window</button>
</div>
</form>
{/* Existing windows */}
{maintenance.length === 0 ? (
<p className="ai-muted ai-muted--maintenance-empty">No maintenance windows scheduled</p>
) : (
<div className="ai-maint-list">
{maintenance.map(w => {
const now = new Date();
const start = new Date(w.start_time);
const end = new Date(w.end_time);
const active = start <= now && end >= now;
const past = end < now;
return (
<div key={w.id} className={`ai-maint-row ${active ? 'maint-active' : past ? 'maint-past' : ''}`}>
<div className="ai-maint-info">
<strong>{w.title}</strong>
{w.endpoint_name && <span className="ai-muted"> - {w.endpoint_name}</span>}
{active && <span className="ai-active-label">In progress</span>}
{past && <span className="ai-past-label">Completed</span>}
<p className="ai-maint-time">
{start.toLocaleString()} {end.toLocaleString()}
</p>
</div>
{!past && (
<button className="btn-delete" onClick={() => handleDeleteMaintenance(w.id)}>Delete</button>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
);
}
export default AdminIncidents;

View File

@@ -0,0 +1,289 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Key, PlusCircle, Trash2, Copy, Check, Globe, Layers, X } from 'lucide-react';
import '../../../styles/features/admin/ApiKeyManager.css';
import { createAdminApiKey, getAdminApiKeys, revokeAdminApiKey } from '../../../shared/api/adminApi';
function ApiKeyManager({ endpoints = [] }) {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [showForm, setShowForm] = useState(false);
const [newKeyData, setNewKeyData] = useState(null); // raw key shown once after creation
const [copiedId, setCopiedId] = useState(null);
const [form, setForm] = useState({
name: '',
scope: 'global',
endpoint_ids: [],
expires_at: ''
});
const fetchKeys = useCallback(async () => {
try {
const res = await getAdminApiKeys();
setKeys(res.data);
} catch (err) {
setError('Failed to load API keys');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchKeys(); }, [fetchKeys]);
async function handleCreate(e) {
e.preventDefault();
setError(null);
setSuccess(null);
try {
const payload = {
name: form.name,
scope: form.scope,
endpoint_ids: form.scope === 'endpoint' ? form.endpoint_ids : undefined,
expires_at: form.expires_at || undefined
};
const res = await createAdminApiKey(payload);
setNewKeyData(res.data);
setShowForm(false);
setForm({ name: '', scope: 'global', endpoint_ids: [], expires_at: '' });
fetchKeys();
} catch (err) {
setError(err.response?.data?.error || 'Failed to create API key');
}
}
async function handleRevoke(id) {
if (!window.confirm('Revoke this API key? Any services using it will lose access immediately.')) return;
try {
await revokeAdminApiKey(id);
setSuccess('API key revoked');
fetchKeys();
} catch (err) {
setError(err.response?.data?.error || 'Failed to revoke key');
}
}
function handleCopy(text, id) {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
});
}
function toggleEndpointId(id) {
setForm(f => ({
...f,
endpoint_ids: f.endpoint_ids.includes(id)
? f.endpoint_ids.filter(x => x !== id)
: [...f.endpoint_ids, id]
}));
}
function formatDate(dt) {
if (!dt) return '-';
return new Date(dt).toLocaleString();
}
function scopeBadge(key) {
if (key.scope === 'global') return <span className="akm-badge akm-badge--global"><Globe size={10} /> Global</span>;
const count = key.endpoint_ids?.length ?? 0;
return <span className="akm-badge akm-badge--endpoint"><Layers size={10} /> {count} endpoint{count !== 1 ? 's' : ''}</span>;
}
return (
<div className="akm">
<div className="akm-header">
<div className="akm-header__left">
<Key size={15} />
<h3>API Keys</h3>
<span className="akm-count">{keys.filter(k => k.active).length} active</span>
</div>
<button className="btn-primary" onClick={() => { setShowForm(s => !s); setError(null); }}>
{showForm ? <><X size={14} /> Cancel</> : <><PlusCircle size={14} /> New Key</>}
</button>
</div>
<p className="akm-description">
API keys grant access to <code>/api/v1</code> endpoints in a universal status format.
Keys can be global (all components) or scoped to specific components.
</p>
{error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>}
{/* One-time raw key display */}
{newKeyData && (
<div className="akm-new-key-banner">
<div className="akm-new-key-banner__header">
<strong>Key created, copy it now. It won't be shown again.</strong>
<button className="akm-new-key-banner__dismiss" onClick={() => setNewKeyData(null)}><X size={14} /></button>
</div>
<div className="akm-new-key-banner__key">
<code>{newKeyData.raw_key}</code>
<button
className="akm-copy-btn"
onClick={() => handleCopy(newKeyData.raw_key, 'new')}
title="Copy to clipboard"
>
{copiedId === 'new' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
</div>
)}
{/* Create form */}
{showForm && (
<div className="card akm-form-card">
<h4>Create New API Key</h4>
<form onSubmit={handleCreate}>
<div className="form-group">
<label>Key Name *</label>
<input
type="text"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Discord Bot Integration"
required
/>
</div>
<div className="form-group">
<label>Scope</label>
<div className="akm-scope-toggle">
<button
type="button"
className={`akm-scope-btn ${form.scope === 'global' ? 'active' : ''}`}
onClick={() => setForm({ ...form, scope: 'global', endpoint_ids: [] })}
>
<Globe size={13} /> Global
</button>
<button
type="button"
className={`akm-scope-btn ${form.scope === 'endpoint' ? 'active' : ''}`}
onClick={() => setForm({ ...form, scope: 'endpoint' })}
>
<Layers size={13} /> Specific Endpoints
</button>
</div>
{form.scope === 'global' && (
<p className="akm-hint">This key can access all components and incidents.</p>
)}
</div>
{form.scope === 'endpoint' && (
<div className="form-group">
<label>Select Endpoints *</label>
<div className="akm-endpoint-list">
{endpoints.filter(ep => !ep.parent_id).map(ep => (
<label key={ep.id} className="akm-endpoint-check">
<input
type="checkbox"
checked={form.endpoint_ids.includes(ep.id)}
onChange={() => toggleEndpointId(ep.id)}
/>
{ep.name}
</label>
))}
{endpoints.length === 0 && <p className="akm-hint">No endpoints configured yet.</p>}
</div>
</div>
)}
<div className="form-group">
<label>Expiry (optional)</label>
<input
type="datetime-local"
value={form.expires_at}
onChange={e => setForm({ ...form, expires_at: e.target.value })}
/>
<span className="akm-hint">Leave blank for a non-expiring key.</span>
</div>
<button
type="submit"
className="btn-primary"
disabled={form.scope === 'endpoint' && form.endpoint_ids.length === 0}
>
Create Key
</button>
</form>
</div>
)}
{/* Keys table */}
<div className="akm-table-wrap">
{loading ? (
<p className="akm-hint">Loading…</p>
) : keys.length === 0 ? (
<p className="akm-hint">No API keys yet. Create one to enable /api/v1 integrations.</p>
) : (
<table className="akm-table">
<thead>
<tr>
<th>Name</th>
<th>Key prefix</th>
<th>Scope</th>
<th>Status</th>
<th>Last used</th>
<th>Expires</th>
<th>Created by</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.map(key => (
<tr key={key.id} className={!key.active ? 'akm-row--revoked' : ''}>
<td className="akm-name">{key.name}</td>
<td>
<div className="akm-prefix-cell">
<code className="akm-prefix">{key.key_prefix}…</code>
<button
className="akm-copy-btn-sm"
onClick={() => handleCopy(key.key_prefix, key.id)}
title="Copy prefix"
>
{copiedId === key.id ? <Check size={11} /> : <Copy size={11} />}
</button>
</div>
</td>
<td>{scopeBadge(key)}</td>
<td>
{key.active
? <span className="akm-status-dot akm-status-dot--active" />
: <span className="akm-status-dot akm-status-dot--revoked" />
}
{key.active ? 'Active' : 'Revoked'}
</td>
<td className="akm-muted">{formatDate(key.last_used_at)}</td>
<td className="akm-muted">{formatDate(key.expires_at)}</td>
<td className="akm-muted">{key.created_by_name || '-'}</td>
<td>
{key.active && (
<button
className="btn-delete"
onClick={() => handleRevoke(key.id)}
title="Revoke key"
>
<Trash2 size={12} /> Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="akm-docs">
<strong>Usage:</strong>&nbsp;
<code>GET /api/v1/status.json</code> with <code>Authorization: Bearer &lt;key&gt;</code> or <code>X-API-Key: &lt;key&gt;</code>.
Public fields are available without a key.
</div>
</div>
);
}
export default ApiKeyManager;

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Trash2, Pencil, GripVertical, X, Check } from 'lucide-react';
import '../../../styles/features/admin/CategoryManager.css';
import {
getAdminCategories,
createAdminCategory,
updateAdminCategory,
deleteAdminCategory,
reorderAdminCategories,
} from '../../../shared/api/adminApi';
function CategoryManager({ onCategoriesChange }) {
const [categories, setCategories] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [formData, setFormData] = useState({ name: '', description: '' });
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState(null);
// Drag state
const dragItem = useRef(null);
const dragOverItem = useRef(null);
const [dragging, setDragging] = useState(false);
useEffect(() => { fetchCategories(); }, []);
async function fetchCategories() {
try {
const res = await getAdminCategories();
setCategories(res.data);
setError(null);
} catch (err) {
setError('Failed to load categories');
} finally {
setLoading(false);
}
}
async function handleSubmit(e) {
e.preventDefault();
setError(null); setSuccess(null);
if (!formData.name.trim()) { setError('Category name is required'); return; }
try {
if (editingId) {
await updateAdminCategory(editingId, formData);
setSuccess('Category updated');
} else {
await createAdminCategory(formData);
setSuccess('Category created');
}
resetForm();
fetchCategories();
if (onCategoriesChange) onCategoriesChange();
} catch (err) {
setError(err.response?.data?.error || 'Failed to save category');
}
}
async function handleDelete(id) {
try {
await deleteAdminCategory(id);
setSuccess('Category deleted');
setConfirmDelete(null);
fetchCategories();
if (onCategoriesChange) onCategoriesChange();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete category');
}
}
function handleEdit(category) {
setFormData({ name: category.name, description: category.description || '' });
setEditingId(category.id);
setShowForm(true);
}
function resetForm() {
setFormData({ name: '', description: '' });
setEditingId(null);
setShowForm(false);
}
// Drag handlers
function onDragStart(index) {
dragItem.current = index;
setDragging(true);
}
function onDragEnter(index) {
dragOverItem.current = index;
// Reorder locally for visual feedback
const updated = [...categories];
const dragged = updated.splice(dragItem.current, 1)[0];
updated.splice(index, 0, dragged);
dragItem.current = index;
setCategories(updated);
}
async function onDragEnd() {
setDragging(false);
dragItem.current = null;
dragOverItem.current = null;
// Persist new order
try {
await reorderAdminCategories(categories.map(c => c.id));
if (onCategoriesChange) onCategoriesChange();
} catch {
setError('Failed to save order');
fetchCategories(); // revert
}
}
if (loading) return (
<div className="cat-mgr">
{[1,2,3].map(i => <div key={i} className="cat-skeleton" />)}
</div>
);
return (
<div className="cat-mgr">
{error && <div className="error" onClick={() => setError(null)}>{error}</div>}
{success && <div className="success" onClick={() => setSuccess(null)}>{success}</div>}
<div className="cat-mgr__toolbar">
<button className="btn-primary" onClick={() => { if (showForm) resetForm(); else setShowForm(true); }}>
{showForm ? <><X size={14} /> Cancel</> : <><Plus size={14} /> New Category</>}
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="cat-form">
<div className="form-group">
<label>Name *</label>
<input type="text" value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API Services" required autoFocus />
</div>
<div className="form-group">
<label>Description</label>
<input type="text" value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional" />
</div>
<div className="cat-form__footer">
<button type="button" className="btn-ghost" onClick={resetForm}>Cancel</button>
<button type="submit" className="btn-primary">
<Check size={14} /> {editingId ? 'Update' : 'Create'}
</button>
</div>
</form>
)}
{categories.length === 0 ? (
<div className="cat-empty">
<p>No categories yet. Create one to organise your endpoints.</p>
</div>
) : (
<div className="cat-list">
<p className="cat-list__hint">Drag to reorder</p>
{categories.map((category, index) => (
<div
key={category.id}
className={`cat-item ${dragging && dragItem.current === index ? 'cat-item--dragging' : ''}`}
draggable
onDragStart={() => onDragStart(index)}
onDragEnter={() => onDragEnter(index)}
onDragEnd={onDragEnd}
onDragOver={e => e.preventDefault()}
>
<span className="cat-item__grip"><GripVertical size={14} /></span>
<div className="cat-item__info">
<strong>{category.name}</strong>
{category.description && <p>{category.description}</p>}
</div>
<span className="cat-item__count">{category.endpoint_count || 0} endpoints</span>
<div className="cat-item__actions">
<button className="btn-icon" onClick={() => handleEdit(category)} title="Edit">
<Pencil size={13} />
</button>
{confirmDelete === category.id ? (
<div className="inline-confirm">
<span>Delete?</span>
<button className="btn-confirm-yes" onClick={() => handleDelete(category.id)}>Yes</button>
<button className="btn-confirm-no" onClick={() => setConfirmDelete(null)}>No</button>
</div>
) : (
<button className="btn-icon btn-icon--danger"
onClick={() => setConfirmDelete(category.id)} title="Delete">
<Trash2 size={13} />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
export default CategoryManager;

View File

@@ -0,0 +1,4 @@
export { default as AdminIncidents } from './AdminIncidents';
export { default as ApiKeyManager } from './ApiKeyManager';
export { default as CategoryManager } from './CategoryManager';
export { default as AdminDashboardSkeleton } from './AdminDashboardSkeleton';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as AdminDashboardPage } from './AdminDashboard';

View File

@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { ShieldCheck, Mail, Lock, LogIn } from 'lucide-react';
import { login } from '../../../shared/api/authApi';
import '../../../styles/features/auth/Login.css';
function Login({ onLogin }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const response = await login({ email, password });
localStorage.setItem('token', response.data.token);
onLogin(response.data.token);
} catch (err) {
setError(err.response?.data?.error || 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="login-container">
<div className="login-box">
<div className="login-logo">
<ShieldCheck size={36} className="login-logo__icon" />
</div>
<h1>Admin Login</h1>
<p className="subtitle">Sign in to manage your status page</p>
<form onSubmit={handleSubmit}>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label><Mail size={12} /> Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
/>
</div>
<div className="form-group">
<label><Lock size={12} /> Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<button type="submit" className="login-btn" disabled={loading}>
{loading ? 'Signing in…' : <><LogIn size={15} /> Sign In</>}
</button>
</form>
</div>
</div>
);
}
export default Login;

View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { checkSetup, setup } from '../../../shared/api/authApi';
import '../../../styles/features/auth/Setup.css';
function Setup({ onSetupComplete }) {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
title: 'My Status Page',
adminName: '',
adminEmail: '',
adminPassword: '',
confirmPassword: '',
publicUrl: '',
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPassword: '',
smtpFromEmail: '',
smtpFromName: '',
smtpTlsMode: 'starttls',
smtpTimeoutMs: 10000,
});
useEffect(() => {
checkSetupStatus();
}, []);
async function checkSetupStatus() {
try {
const response = await checkSetup();
// If we get a 200 response, setup is required - proceed with setup
if (response.status === 200) {
setLoading(false);
}
} catch (err) {
// If we get a 400, setup is already complete
if (err.response?.status === 400) {
setError('Setup has already been completed');
setLoading(false);
} else {
setLoading(false);
}
}
}
async function handleSetup(e) {
e.preventDefault();
if (formData.adminPassword !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
if (formData.adminPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
const response = await setup({
title: formData.title,
adminName: formData.adminName,
adminEmail: formData.adminEmail,
adminPassword: formData.adminPassword,
publicUrl: formData.publicUrl,
smtpHost: formData.smtpHost,
smtpPort: formData.smtpPort,
smtpUser: formData.smtpUser,
smtpPassword: formData.smtpPassword,
smtpFromEmail: formData.smtpFromEmail,
smtpFromName: formData.smtpFromName,
smtpTlsMode: formData.smtpTlsMode,
smtpTimeoutMs: formData.smtpTimeoutMs,
});
localStorage.setItem('token', response.data.token);
onSetupComplete();
} catch (err) {
setError(err.response?.data?.error || 'Setup failed');
} finally {
setLoading(false);
}
}
if (loading) {
return <div className="setup-container"><div className="setup-loading">Checking setup status...</div></div>;
}
if (error) {
return <div className="setup-container"><div className="setup-error">{error}</div></div>;
}
return (
<div className="setup-container">
<div className="setup-wrapper">
<div className="setup-header">
<h1>Status Page Setup</h1>
<p className="setup-subtitle">Welcome! Let's get your status page configured</p>
</div>
<div className="setup-progress">
<div className={`progress-step ${step >= 1 ? 'active' : ''}`}>
<div className="step-number">1</div>
<div className="step-label">Site Settings</div>
</div>
<div className={`progress-step ${step >= 2 ? 'active' : ''}`}>
<div className="step-number">2</div>
<div className="step-label">Admin Account</div>
</div>
<div className={`progress-step ${step >= 3 ? 'active' : ''}`}>
<div className="step-number">3</div>
<div className="step-label">Email (Optional)</div>
</div>
</div>
<form onSubmit={handleSetup} className="setup-form">
{step === 1 && (
<div className="setup-fields">
<h2>Site Settings</h2>
<div className="form-group">
<label>Website Title</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="My Status Page"
required
/>
<small>This appears on your status page</small>
</div>
<div className="form-group">
<label>Public Status Page URL</label>
<input
type="url"
value={formData.publicUrl}
onChange={(e) => setFormData({ ...formData, publicUrl: e.target.value })}
placeholder="https://status.example.com"
/>
<small>Used in email notifications (defaults to localhost if not set).</small>
</div>
<button type="button" onClick={() => setStep(2)} className="btn-next">
Next →
</button>
</div>
)}
{step === 2 && (
<div className="setup-fields">
<h2>Create Admin Account</h2>
<div className="form-group">
<label>Full Name</label>
<input
type="text"
value={formData.adminName}
onChange={(e) => setFormData({ ...formData, adminName: e.target.value })}
placeholder="Your Name"
required
/>
</div>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@example.com"
required
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={formData.adminPassword}
onChange={(e) => setFormData({ ...formData, adminPassword: e.target.value })}
placeholder="••••••••"
required
minLength="8"
/>
<small>Minimum 8 characters, with uppercase, lowercase, number, and special character</small>
</div>
<div className="form-group">
<label>Confirm Password</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="••••••••"
required
/>
</div>
<div className="button-group">
<button type="button" onClick={() => setStep(1)} className="btn-back">
← Back
</button>
<button type="button" onClick={() => setStep(3)} className="btn-next">
Next →
</button>
</div>
</div>
)}
{step === 3 && (
<div className="setup-fields">
<h2>Email Notifications (Optional)</h2>
<p className="step-info">Configure SMTP to send email notifications. You can skip this for now.</p>
<div className="form-group">
<label>SMTP Host</label>
<input
type="text"
value={formData.smtpHost}
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
placeholder="smtp.gmail.com"
/>
<small>e.g., smtp.gmail.com or your provider</small>
</div>
<div className="form-group">
<label>SMTP Port</label>
<input
type="number"
value={formData.smtpPort}
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
placeholder="587"
/>
</div>
<div className="form-group">
<label>SMTP Username</label>
<input
type="text"
value={formData.smtpUser}
onChange={(e) => setFormData({ ...formData, smtpUser: e.target.value })}
placeholder="your-email@gmail.com"
/>
</div>
<div className="form-group">
<label>From Email</label>
<input
type="email"
value={formData.smtpFromEmail}
onChange={(e) => setFormData({ ...formData, smtpFromEmail: e.target.value })}
placeholder="status@example.com"
/>
</div>
<div className="form-group">
<label>From Name</label>
<input
type="text"
value={formData.smtpFromName}
onChange={(e) => setFormData({ ...formData, smtpFromName: e.target.value })}
placeholder="Status Bot"
/>
</div>
<div className="form-group">
<label>TLS Mode</label>
<select
value={formData.smtpTlsMode}
onChange={(e) => setFormData({ ...formData, smtpTlsMode: e.target.value })}
>
<option value="starttls">STARTTLS</option>
<option value="tls">TLS</option>
<option value="none">None</option>
</select>
</div>
<div className="form-group">
<label>SMTP Password</label>
<input
type="password"
value={formData.smtpPassword}
onChange={(e) => setFormData({ ...formData, smtpPassword: e.target.value })}
placeholder="••••••••"
/>
</div>
<div className="form-group">
<label>SMTP Timeout (ms)</label>
<input
type="number"
min="1000"
max="120000"
value={formData.smtpTimeoutMs}
onChange={(e) => setFormData({ ...formData, smtpTimeoutMs: parseInt(e.target.value, 10) || 10000 })}
placeholder="10000"
/>
</div>
<div className="button-group">
<button type="button" onClick={() => setStep(2)} className="btn-back">
← Back
</button>
<button type="submit" className="btn-complete" disabled={loading}>
{loading ? 'Setting up...' : 'Complete Setup'}
</button>
</div>
</div>
)}
{error && <div className="setup-error-message">{error}</div>}
</form>
</div>
</div>
);
}
export default Setup;

View File

@@ -0,0 +1,2 @@
export { default as LoginPage } from './Login';
export { default as SetupPage } from './Setup';

View File

@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import Markdown from 'markdown-to-jsx';
import '../../../styles/features/incidents/IncidentBanner.css';
const SEVERITY_ORDER = { down: 0, degraded: 1 };
const STATUS_LABELS = {
investigating: 'Investigating',
identified: 'Identified',
monitoring: 'Monitoring',
resolved: 'Resolved',
};
function IncidentBanner({ incidents = [] }) {
const [dismissed, setDismissed] = useState(new Set());
const [expanded, setExpanded] = useState(new Set());
const active = incidents
.filter(i => !i.resolved_at && !dismissed.has(i.id))
.sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2));
if (active.length === 0) return null;
const toggleExpand = (id) => {
setExpanded(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const dismiss = (e, id) => {
e.stopPropagation();
setDismissed(prev => new Set([...prev, id]));
};
return (
<div className="incident-banner-stack">
{active.map(incident => {
const isExpanded = expanded.has(incident.id);
const severityClass = incident.severity === 'down' ? 'banner-critical' : 'banner-degraded';
const preview = incident.latest_update?.message || incident.description;
return (
<div key={incident.id} className={`incident-banner ${severityClass}`}>
<div
className="banner-main"
onClick={() => toggleExpand(incident.id)}
role="button"
aria-expanded={isExpanded}
>
<div className="banner-left">
<span className="banner-dot" />
<div className="banner-text">
<div className="banner-title-row">
<strong className="banner-title">{incident.title}</strong>
<span className="banner-status-pill">
{STATUS_LABELS[incident.status] || incident.status}
</span>
{incident.source === 'auto' && (
<span className="banner-source-pill">Auto-detected</span>
)}
</div>
{!isExpanded && preview && (
<p className="banner-preview">
<Markdown options={{ disableParsingRawHTML: true }}>{preview}</Markdown>
</p>
)}
</div>
</div>
<div className="banner-actions">
<span className="banner-toggle">{isExpanded ? '▲' : '▼'}</span>
<button
className="banner-dismiss"
onClick={e => dismiss(e, incident.id)}
title="Dismiss"
aria-label="Dismiss incident banner"
>
</button>
</div>
</div>
{isExpanded && (
<div className="banner-detail">
{incident.updates && incident.updates.length > 0 ? (
<div className="banner-timeline">
{[...incident.updates].reverse().map(u => (
<div key={u.id} className="banner-update">
<div className="banner-update-meta">
{u.status_label && (
<span className={`status-pill pill-${u.status_label}`}>
{STATUS_LABELS[u.status_label] || u.status_label}
</span>
)}
<span className="banner-update-time">
{new Date(u.created_at).toLocaleString()}
</span>
{u.created_by !== 'system' && (
<span className="banner-update-author">- {u.created_by}</span>
)}
</div>
<div className="banner-update-body">
<Markdown options={{ disableParsingRawHTML: true }}>{u.message}</Markdown>
</div>
</div>
))}
</div>
) : (
preview && (
<div className="banner-update-body">
<Markdown options={{ disableParsingRawHTML: true }}>{preview}</Markdown>
</div>
)
)}
{incident.endpoints?.length > 0 && (
<div className="banner-affected">
<span className="banner-affected-label">Affected services:</span>
{incident.endpoints.map(ep => (
<span key={ep.id} className="banner-service-tag">{ep.name}</span>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
export default IncidentBanner;

View File

@@ -0,0 +1,286 @@
import React, { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import Markdown from 'markdown-to-jsx';
import { format, parseISO, subDays } from 'date-fns';
import IncidentTimeline from './IncidentTimeline';
import '../../../styles/features/incidents/IncidentHistory.css';
import { getPublicIncidentById } from '../../../shared/api/publicApi';
const STATUS_LABELS = {
investigating: 'Investigating',
identified: 'Identified',
monitoring: 'Monitoring',
resolved: 'Resolved',
};
const HISTORY_DAYS = 90;
const INITIAL_RESOLVED_COUNT = 5;
function IncidentHistory({ incidents = [] }) {
const navigate = useNavigate();
const [expanded, setExpanded] = useState(new Set());
const [fullUpdates, setFullUpdates] = useState({});
const [loadingId, setLoadingId] = useState(null);
const [showAllResolved, setShowAllResolved] = useState(false);
const cutoff = subDays(new Date(), HISTORY_DAYS);
const activeIncidents = incidents.filter(i => !i.resolved_at);
const resolvedIncidents = incidents
.filter(i => i.resolved_at && new Date(i.start_time) > cutoff)
.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
const visibleResolved = showAllResolved
? resolvedIncidents
: resolvedIncidents.slice(0, INITIAL_RESOLVED_COUNT);
const toggleExpand = useCallback(
async (incident) => {
const id = incident.id;
setExpanded(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
if (!fullUpdates[id]) {
try {
setLoadingId(id);
const res = await getPublicIncidentById(id);
setFullUpdates(prev => ({
...prev,
[id]: {
updates: res.data.updates || [],
post_mortem: res.data.post_mortem || null,
},
}));
} catch (err) {
console.error('Failed to load incident updates:', err);
} finally {
setLoadingId(null);
}
}
},
[fullUpdates]
);
const calculateDuration = (incident) => {
if (!incident.resolved_at) return 'Ongoing';
const minutes = Math.round(
(new Date(incident.resolved_at) - new Date(incident.start_time)) / 60000
);
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
return `${Math.round(minutes / 1440)}d`;
};
const formatTimeRange = (incident, isResolved) => {
if (isResolved) {
return `${format(parseISO(incident.start_time), 'MMMM d, yyyy')} at ${format(
parseISO(incident.start_time),
'h:mm a'
)} ${format(parseISO(incident.resolved_at), 'h:mm a')}`;
}
return `${format(parseISO(incident.start_time), 'MMMM d, yyyy')} at ${format(
parseISO(incident.start_time),
'h:mm a'
)}`;
};
const latestPreview =
(incident) => incident?.latest_update?.message?.split('\n')[0] || incident?.description || '';
const renderAffectedServices = (incident) => {
if (!incident.endpoints?.length) return null;
return (
<div className="affected-services">
<span className="affected-label">Affected services</span>
<div className="service-tags">
{incident.endpoints.map((ep) => (
<span key={ep.id} className="service-tag">
{ep.name}
</span>
))}
</div>
</div>
);
};
const renderIncidentCard = (incident, isResolved = false) => {
const isExpanded = expanded.has(incident.id);
const isLoading = loadingId === incident.id;
const hasUpdates = incident.latest_update != null;
const detail = fullUpdates[incident.id];
const updates =
detail?.updates ?? (incident.latest_update ? [incident.latest_update] : []);
const postMortem = detail?.post_mortem ?? incident.post_mortem ?? null;
const duration = calculateDuration(incident);
const preview = latestPreview(incident);
const clickableProps = hasUpdates
? {
onClick: () => toggleExpand(incident),
role: 'button',
'aria-expanded': isExpanded,
style: { cursor: 'pointer' },
}
: {
style: { cursor: 'default' },
};
return (
<article
key={incident.id}
className={`incident-item ${
isResolved ? 'incident-item--resolved' : 'incident-item--active'
} ${isExpanded ? 'incident-item--expanded' : ''}`}
>
<div className="incident-header" {...clickableProps}>
<div className="incident-main-content">
{incident.status && (
<div className="incident-status-badge-wrapper">
<span className={`incident-status-badge status-badge-${incident.status}`}>
{STATUS_LABELS[incident.status] || incident.status}
</span>
</div>
)}
<div className="incident-title-row">
<a
href={`/incident/${incident.id}`}
className="incident-title-link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/incident/${incident.id}`);
}}
title="Permalink to this incident"
>
{incident.title}
</a>
{incident.source === 'auto' && (
<span className="incident-pill pill-auto">Auto</span>
)}
</div>
<p className="incident-time">{formatTimeRange(incident, isResolved)}</p>
</div>
<div className="incident-right-section">
<span
className={`duration-badge ${
isResolved ? 'duration-badge--resolved' : 'duration-badge--active'
}`}
>
{duration}
</span>
{hasUpdates && (
<span className="expand-toggle" aria-hidden="true">
{isExpanded ? '▲' : '▼'}
</span>
)}
</div>
</div>
{!isExpanded && (
<div className="incident-body">
{preview && (
<div className="incident-latest-update">
<Markdown options={{ disableParsingRawHTML: true, forceInline: true }}>
{preview}
</Markdown>
</div>
)}
{renderAffectedServices(incident)}
</div>
)}
{isExpanded && (
<div className="incident-expanded">
{preview && (
<div className="incident-latest-update">
<Markdown options={{ disableParsingRawHTML: true }}>
{preview}
</Markdown>
</div>
)}
{renderAffectedServices(incident)}
<div className="incident-timeline-section">
{isLoading ? (
<p className="timeline-loading">Loading history</p>
) : (
<IncidentTimeline updates={updates} postMortem={postMortem} />
)}
</div>
</div>
)}
</article>
);
};
if (activeIncidents.length === 0 && resolvedIncidents.length === 0) {
return (
<div className="incident-history">
<h3 className="incident-history__title">Incident History</h3>
<div className="no-incidents">
<p>No incidents in the past {HISTORY_DAYS} days. </p>
</div>
</div>
);
}
return (
<div className="incident-history">
<h3 className="incident-history__title">Incident History</h3>
{activeIncidents.length > 0 && (
<div className="incidents-section incidents-section--active">
<div className="section-header section-header--active">
<span>Active Incidents</span>
<span className="section-header__count">{activeIncidents.length}</span>
</div>
<div className="incidents-list">
{activeIncidents.map((incident) => renderIncidentCard(incident, false))}
</div>
</div>
)}
{resolvedIncidents.length > 0 && (
<div className="incidents-section incidents-section--resolved">
<div className="section-header section-header--resolved">
<span>Resolved - Past {HISTORY_DAYS} Days</span>
<span className="section-header__count">{resolvedIncidents.length}</span>
</div>
<div className="incidents-list">
{visibleResolved.map((incident) => renderIncidentCard(incident, true))}
</div>
{resolvedIncidents.length > INITIAL_RESOLVED_COUNT && (
<button
className="show-more-btn"
onClick={() => setShowAllResolved((v) => !v)}
>
{showAllResolved
? 'Show less'
: `Show ${resolvedIncidents.length - INITIAL_RESOLVED_COUNT} more resolved`}
</button>
)}
</div>
)}
</div>
);
}
export default IncidentHistory;

View File

@@ -0,0 +1,68 @@
import React from 'react';
import Markdown from 'markdown-to-jsx';
import '../../../styles/features/incidents/IncidentTimeline.css';
const STATUS_LABELS = {
investigating: 'Investigating',
identified: 'Identified',
monitoring: 'Monitoring',
resolved: 'Resolved',
};
function IncidentTimeline({ updates = [], postMortem }) {
if (updates.length === 0 && !postMortem) {
return <p className="timeline-empty">No updates posted yet.</p>;
}
// Show newest first
const sorted = [...updates].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return (
<div className="incident-timeline">
{/* Post-mortem block, shown at the top as the final word on the incident */}
{postMortem && (
<div className="timeline-postmortem">
<div className="timeline-postmortem-header">
<span className="timeline-postmortem-icon">📋</span>
<span className="timeline-postmortem-label">Post-Mortem</span>
</div>
<div className="timeline-postmortem-body">
<Markdown options={{ disableParsingRawHTML: true }}>{postMortem}</Markdown>
</div>
</div>
)}
{sorted.map((update, index) => (
<div key={update.id} className={`timeline-entry ${index === 0 ? 'timeline-latest' : ''}`}>
<div className="timeline-line">
<div className="timeline-node" />
{index < sorted.length - 1 && <div className="timeline-connector" />}
</div>
<div className="timeline-content">
<div className="timeline-meta">
{update.status_label && (
<span className={`timeline-status-pill pill-${update.status_label}`}>
{STATUS_LABELS[update.status_label] || update.status_label}
</span>
)}
<span className="timeline-time">
{new Date(update.created_at).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
{update.created_by && update.created_by !== 'system' && (
<span className="timeline-author">- {update.created_by}</span>
)}
</div>
<div className="timeline-body">
<Markdown options={{ disableParsingRawHTML: true }}>{update.message}</Markdown>
</div>
</div>
</div>
))}
</div>
);
}
export default IncidentTimeline;

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { Wrench, CalendarClock } from 'lucide-react';
import '../../../styles/features/incidents/MaintenanceNotice.css';
function useCountdown(targetDate) {
const [timeLeft, setTimeLeft] = useState(() => Math.max(0, new Date(targetDate) - Date.now()));
useEffect(() => {
const tick = () => setTimeLeft(Math.max(0, new Date(targetDate) - Date.now()));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [targetDate]);
return timeLeft;
}
function formatCountdown(ms) {
if (ms <= 0) return 'Starting now';
const totalSecs = Math.floor(ms / 1000);
const days = Math.floor(totalSecs / 86400);
const hours = Math.floor((totalSecs % 86400) / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m ${secs}s`;
}
function CountdownBadge({ startTime }) {
const ms = useCountdown(startTime);
return (
<span className="maint-countdown">
Starts in <strong>{formatCountdown(ms)}</strong>
</span>
);
}
function MaintenanceNotice({ windows = [] }) {
const [dismissed, setDismissed] = useState(new Set());
const now = new Date();
const active = windows.filter(w =>
new Date(w.start_time) <= now &&
new Date(w.end_time) >= now &&
!dismissed.has(w.id)
);
const upcoming = windows.filter(w =>
new Date(w.start_time) > now &&
!dismissed.has(w.id) &&
// Only show if starting within 24h
(new Date(w.start_time) - now) < 24 * 60 * 60 * 1000
);
const visible = [...active, ...upcoming];
if (visible.length === 0) return null;
const formatRange = (start, end) => {
const s = new Date(start);
const e = new Date(end);
const opts = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
return `${s.toLocaleString(undefined, opts)} ${e.toLocaleString(undefined, opts)}`;
};
return (
<div className="maintenance-stack">
{visible.map(w => {
const isActive = new Date(w.start_time) <= now;
return (
<div key={w.id} className={`maintenance-notice ${isActive ? 'maint-active' : 'maint-upcoming'}`}>
<div className="maint-icon">
{isActive
? <Wrench size={18} color="#3b82f6" />
: <CalendarClock size={18} color="#f59e0b" />}
</div>
<div className="maint-body">
<strong className="maint-title">{w.title}</strong>
{w.endpoint_name && (
<span className="maint-scope">- {w.endpoint_name}</span>
)}
{isActive
? <span className="maint-label maint-label--active">In progress</span>
: <CountdownBadge startTime={w.start_time} />
}
<p className="maint-time">{formatRange(w.start_time, w.end_time)}</p>
{w.description && <p className="maint-desc">{w.description}</p>}
</div>
<button
className="maint-dismiss"
onClick={() => setDismissed(prev => new Set([...prev, w.id]))}
title="Dismiss"
aria-label="Dismiss maintenance notice"
>
</button>
</div>
);
})}
</div>
);
}
export default MaintenanceNotice;

View File

@@ -0,0 +1,4 @@
export { default as IncidentHistory } from './IncidentHistory';
export { default as IncidentTimeline } from './IncidentTimeline';
export { default as IncidentBanner } from './IncidentBanner';
export { default as MaintenanceNotice } from './MaintenanceNotice';

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { format, parseISO } from 'date-fns';
import { ArrowLeft, Sun, Moon } from 'lucide-react';
import { useTheme } from '../../../context/ThemeContext';
import { IncidentTimeline, IncidentBanner, MaintenanceNotice } from '../components';
import {
getPublicEndpoints,
getPublicIncidents,
getPublicIncidentById,
getPublicMaintenance,
} from '../../../shared/api/publicApi';
import { getSettings } from '../../../shared/api/authApi';
import { INCIDENT_STATUS_LABELS } from '../../../shared/constants/status';
import { getOverallStatus } from '../../../shared/utils/status';
import { formatDuration } from '../../../shared/utils/format';
import '../../../styles/base/App.css';
import '../../../styles/features/incidents/IncidentPage.css';
function IncidentPage() {
const { incidentId } = useParams();
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const [incident, setIncident] = useState(null);
const [endpoints, setEndpoints] = useState([]);
const [incidents, setIncidents] = useState([]);
const [maintenance, setMaintenance] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [siteTitle, setSiteTitle] = useState('Status Page');
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [incidentId]);
const fetchData = async () => {
try {
setLoading(true);
setError(null);
console.log('Fetching incident data for ID:', incidentId);
const [incidentRes, endpointsRes, incidentsRes, maintenanceRes, settingsRes] = await Promise.all([
getPublicIncidentById(incidentId),
getPublicEndpoints(),
getPublicIncidents(),
getPublicMaintenance(),
getSettings().catch(() => ({ data: { title: 'Status Page' } })),
]);
console.log('Incident data loaded:', incidentRes.data);
setIncident(incidentRes.data);
setEndpoints(endpointsRes.data);
setIncidents(incidentsRes.data);
setMaintenance(maintenanceRes.data);
if (settingsRes.data?.title) setSiteTitle(settingsRes.data.title);
setError(null);
} catch (err) {
console.error('Failed to load incident:', err);
console.error('Error details:', {
message: err.message,
response: err.response?.data,
status: err.response?.status,
});
setError('Failed to load incident details');
} finally {
setLoading(false);
}
};
const activeIncidents = incidents.filter(i => !i.resolved_at);
const duration = incident ? formatDuration(incident.start_time, incident.resolved_at) : 'Ongoing';
const isResolved = incident?.resolved_at != null;
return (
<div className="app">
<div className="container">
<div className="header">
<h1>{siteTitle}</h1>
<div className="header-actions">
<button onClick={toggleTheme} className="theme-toggle-btn" title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
<button onClick={() => navigate('/')} className="back-btn">
<ArrowLeft size={14} /> Back to Status
</button>
</div>
</div>
<div className="card status-overview">
<h2>System Status: <strong>{getOverallStatus(endpoints)}</strong></h2>
<p className="last-updated">Last updated: {new Date().toLocaleTimeString()}</p>
</div>
<MaintenanceNotice windows={maintenance} />
<IncidentBanner incidents={activeIncidents} />
{loading && (
<div className="card incident-state-card">
<p className="incident-state-card__text">Loading incident details...</p>
</div>
)}
{error && !loading && (
<div className="card incident-state-card">
<p className="incident-state-card__error">{error}</p>
<div className="incident-state-card__actions">
<button onClick={fetchData} className="back-btn">
Retry
</button>
<button onClick={() => navigate('/')} className="back-btn">
Return to Status Page
</button>
</div>
</div>
)}
{!loading && !error && incident && (
<div className="card incident-detail-card">
<div className="incident-detail-header">
<div className="incident-detail-status">
<span className={`incident-status-badge status-badge-${incident.status}`}>
{INCIDENT_STATUS_LABELS[incident.status] || incident.status}
</span>
{incident.source === 'auto' && (
<span className="incident-pill pill-auto">Auto-detected</span>
)}
<span className={`duration-badge ${isResolved ? 'duration-badge--resolved' : 'duration-badge--active'}`}>
{duration}
</span>
</div>
<h1 className="incident-detail-title">{incident.title}</h1>
<div className="incident-detail-meta">
<div className="incident-detail-time">
{isResolved && incident.resolved_at ? (
<>
<strong>Started:</strong> {incident.start_time ? `${format(parseISO(incident.start_time), 'MMMM d, yyyy')} at ${format(parseISO(incident.start_time), 'h:mm a')}` : 'Unknown'}
<br />
<strong>Resolved:</strong> {format(parseISO(incident.resolved_at), 'MMMM d, yyyy')} at {format(parseISO(incident.resolved_at), 'h:mm a')}
</>
) : (
<>
<strong>Started:</strong> {incident.start_time ? `${format(parseISO(incident.start_time), 'MMMM d, yyyy')} at ${format(parseISO(incident.start_time), 'h:mm a')}` : 'Unknown'}
</>
)}
</div>
{incident.endpoints && incident.endpoints.length > 0 && (
<div className="affected-services">
<strong>Affected Services:</strong>
<div className="service-tags">
{incident.endpoints.map(ep => (
<span key={ep.id} className="service-tag">{ep.name}</span>
))}
</div>
</div>
)}
</div>
</div>
<div className="incident-detail-timeline">
<h2>Incident Timeline</h2>
<IncidentTimeline
updates={incident.updates || []}
postMortem={incident.post_mortem}
/>
</div>
</div>
)}
</div>
</div>
);
}
export default IncidentPage;

View File

@@ -0,0 +1 @@
export { default as IncidentPage } from './IncidentPage';

View File

@@ -0,0 +1,333 @@
import React from 'react';
import {
ArrowLeft,
Globe,
Copy,
ExternalLink,
Activity,
Clock3,
ShieldAlert,
Wrench,
CheckCircle2,
AlertTriangle,
XCircle,
} from 'lucide-react';
import { IncidentHistory } from '../../incidents/components';
import '../../../styles/features/status/EndpointDetailView.css';
function getEndpointState(endpoint) {
const raw = String(endpoint?.status || '').toLowerCase();
if (raw === 'up' || raw === 'operational') {
return {
key: 'up',
label: 'All systems operational',
icon: <CheckCircle2 size={16} />,
};
}
if (raw === 'degraded') {
return {
key: 'degraded',
label: 'Performance degraded',
icon: <AlertTriangle size={16} />,
};
}
if (raw === 'down') {
return {
key: 'down',
label: 'Service disruption',
icon: <XCircle size={16} />,
};
}
return {
key: 'unknown',
label: 'Status unknown',
icon: <Activity size={16} />,
};
}
function formatResponse(ms) {
if (ms == null || Number.isNaN(Number(ms))) return '-';
return `${Math.round(Number(ms))}ms`;
}
function formatPct(v) {
if (v == null || Number.isNaN(Number(v))) return '-';
return `${Number(v).toFixed(2)}%`;
}
function renderUrl(endpoint) {
return endpoint?.url || endpoint?.domain || endpoint?.target || 'No URL';
}
function copyToClipboard(text) {
if (!text) return;
navigator.clipboard?.writeText(text).catch(() => {});
}
export default function EndpointDetailView({
selectedEndpoint,
endpointIncidents = [],
endpointMaintenances = [],
stats = {},
uptime = {},
recentEvents = [],
responseChart = null,
uptimeChart = null,
historyHeatmap = null,
themeToggle = null,
onBack,
}) {
const state = getEndpointState(selectedEndpoint);
const hasIncidents = endpointIncidents.length > 0;
const hasMaintenance = endpointMaintenances.length > 0;
const url = renderUrl(selectedEndpoint);
return (
<div className="app status-page endpoint-page">
<div className="status-page-content">
<div className="status-page-header card">
<div className="status-page-header__title">
<h1>ArcaneNeko Status</h1>
</div>
<div className="status-page-header__actions">
{themeToggle}
<button
className="back-to-services-btn"
onClick={onBack}
type="button"
>
<ArrowLeft size={16} />
<span>Back to All Services</span>
</button>
</div>
</div>
<section className={`card endpoint-hero endpoint-hero--${state.key}`}>
<div className={`endpoint-hero__accent endpoint-hero__accent--${state.key}`} />
<div className="endpoint-hero__content">
<div className="endpoint-hero__top">
<div className="endpoint-hero__identity">
<h2 className="endpoint-hero__name">
{selectedEndpoint?.name || 'Unnamed Service'}
</h2>
<div className="endpoint-hero__url-row">
{selectedEndpoint?.url ? (
<a
href={selectedEndpoint.url}
target="_blank"
rel="noreferrer"
className="endpoint-hero__url"
>
<Globe size={14} />
<span>{url}</span>
<ExternalLink size={12} className="url-external-icon" />
</a>
) : (
<span className="endpoint-hero__url endpoint-hero__url--plain">
<Globe size={14} />
<span>{url}</span>
</span>
)}
<button
className="copy-btn"
type="button"
onClick={() => copyToClipboard(url)}
title="Copy URL"
>
<Copy size={14} />
</button>
</div>
</div>
<div className="endpoint-hero__side">
<div className={`status-callout status-callout--${state.key}`}>
<span className="status-callout__icon">{state.icon}</span>
<span className="status-callout__text">{state.label}</span>
</div>
<div className="endpoint-hero__badges">
{hasIncidents && (
<span className="badge-incident">
<ShieldAlert size={12} />
Affected by incident
</span>
)}
{hasMaintenance && (
<span className="badge-maintenance">
<Wrench size={12} />
Maintenance
</span>
)}
</div>
</div>
</div>
<div className="endpoint-meta-chips">
{selectedEndpoint?.type && (
<span className="meta-chip">
<Activity size={12} />
{String(selectedEndpoint.type).toUpperCase()}
</span>
)}
{selectedEndpoint?.checkInterval && (
<span className="meta-chip">
<Clock3 size={12} />
Every {selectedEndpoint.checkInterval}s
</span>
)}
{selectedEndpoint?.timeout && (
<span className="meta-chip">
<Clock3 size={12} />
Timeout {selectedEndpoint.timeout}s
</span>
)}
</div>
<div className="endpoint-glance-grid">
<div className="glance-card">
<span className="glance-label">Response</span>
<strong className="glance-value">
{formatResponse(
stats?.avgResponseTime ??
stats?.responseTime ??
selectedEndpoint?.responseTime
)}
</strong>
</div>
<div className="glance-card">
<span className="glance-label">30D Uptime</span>
<strong className="glance-value">
{formatPct(uptime?.percentage ?? selectedEndpoint?.uptime30d)}
</strong>
</div>
<div className="glance-card">
<span className="glance-label">Last Check</span>
<strong className="glance-value">
{selectedEndpoint?.lastCheckFormatted ||
selectedEndpoint?.lastChecked ||
'-'}
</strong>
</div>
<div className="glance-card">
<span className="glance-label">Checks</span>
<strong className="glance-value">
{uptime?.totalChecks ?? selectedEndpoint?.totalChecks ?? '-'}
</strong>
</div>
</div>
</div>
</section>
<div className="endpoint-dashboard endpoint-dashboard--balanced">
<aside className="endpoint-dashboard__sidebar">
<section className="card section-card">
<h3 className="section-title">Health Summary</h3>
<div className="kpi-grid">
<div className="kpi-box">
<span className="kpi-box__value kpi-box__value--danger">
{stats?.loss != null ? `${stats.loss}%` : '-'}
</span>
<span className="kpi-box__label">Loss</span>
</div>
<div className="kpi-box">
<span className="kpi-box__value">{formatResponse(stats?.avg)}</span>
<span className="kpi-box__label">Avg</span>
</div>
<div className="kpi-box">
<span className="kpi-box__value">{formatResponse(stats?.min)}</span>
<span className="kpi-box__label">Min</span>
</div>
<div className="kpi-box">
<span className="kpi-box__value">{formatResponse(stats?.jitter)}</span>
<span className="kpi-box__label">Jitter</span>
</div>
<div className="kpi-box">
<span className="kpi-box__value">{formatResponse(stats?.max)}</span>
<span className="kpi-box__label">Max</span>
</div>
<div className="kpi-box">
<span className="kpi-box__value">
{uptime?.successfulChecks ?? '-'}
</span>
<span className="kpi-box__label">Successful</span>
</div>
</div>
</section>
<section className="card section-card">
<h3 className="section-title">30 Day Uptime</h3>
<div className="endpoint-sidebar-chart">
{uptimeChart || (
<div className="endpoint-empty-state">No uptime data.</div>
)}
</div>
</section>
</aside>
<main className="endpoint-dashboard__main">
<section className="card section-card chart-section chart-section--large">
{responseChart || (
<div className="endpoint-empty-state">No response chart data.</div>
)}
</section>
<section className="card section-card chart-section">
{historyHeatmap || (
<div className="endpoint-empty-state">No history data.</div>
)}
</section>
<section className="card section-card">
<h3 className="section-title">Recent Events</h3>
<div className="events-timeline">
{recentEvents?.length ? (
recentEvents.map((event, idx) => (
<div className="event-item" key={`${event.label || 'event'}-${idx}`}>
<span className={`event-dot event-dot--${event.status || 'unknown'}`} />
<div className="event-content">
<span className="event-status">{event.label}</span>
<span className="event-time">{event.time}</span>
</div>
</div>
))
) : (
<div className="endpoint-empty-state">No recent events.</div>
)}
</div>
</section>
</main>
</div>
{endpointIncidents.length > 0 && (
<section className="card section-card endpoint-incidents-full">
<div className="section-title-row">
<h3 className="section-title">Service Incidents</h3>
</div>
<IncidentHistory incidents={endpointIncidents} />
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,311 @@
import React, { useState, useEffect, useRef } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { format, parseISO } from 'date-fns';
import { getEndpointResponseTimes } from '../../../shared/api/publicApi';
import '../../../styles/features/status/ResponseTimeChart.css';
const PRESETS = [
{ label: '24h', hours: 24 },
{ label: '7d', days: 7 },
{ label: '30d', days: 30 },
];
function ResponseTimeChart({ endpointId }) {
const [preset, setPreset] = useState(PRESETS[0]);
const [chartData, setChartData] = useState([]);
const [granularity, setGranularity] = useState('day');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [stats, setStats] = useState(null);
const chartRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '100px' }
);
if (chartRef.current) {
observer.observe(chartRef.current);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!endpointId || !isVisible) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
const params = preset.hours ? { hours: preset.hours } : { days: preset.days };
getEndpointResponseTimes(endpointId, params)
.then((res) => {
if (cancelled) return;
const g = res.data.granularity || 'day';
setGranularity(g);
const fmt = g === 'hour' ? 'HH:mm' : 'MMM d';
const rows = (res.data.data || []).map((row) => ({
time: format(parseISO(row.day), fmt),
avg: row.avg,
min: row.min,
max: row.max,
checks: row.checks,
}));
setChartData(rows);
if (rows.length > 0) {
const avgs = rows.map((r) => r.avg).filter((n) => n != null);
const mins = rows.map((r) => r.min).filter((n) => n != null);
const maxs = rows.map((r) => r.max).filter((n) => n != null);
setStats({
min: mins.length ? Math.min(...mins) : null,
avg:
avgs.length
? avgs.reduce((a, b) => a + b, 0) / avgs.length
: null,
max: maxs.length ? Math.max(...maxs) : null,
});
} else {
setStats(null);
}
})
.catch((err) => {
if (!cancelled) setError('Failed to load response time data');
console.error(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [endpointId, preset, isVisible]);
if (!isVisible) {
return (
<div className="response-time-chart" ref={chartRef}>
<div className="chart-empty">
<p>Scroll to load chart data...</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="response-time-chart" ref={chartRef}>
<div className="chart-empty">
<p>Loading response time data...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="response-time-chart" ref={chartRef}>
<div className="chart-empty">
<p>{error}</p>
</div>
</div>
);
}
if (chartData.length === 0) {
return (
<div className="response-time-chart" ref={chartRef}>
<div className="chart-empty">
<p>No checks recorded yet</p>
</div>
</div>
);
}
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const d = payload[0]?.payload;
return (
<div className="rt-tooltip">
<p className="rt-tooltip__label">{label}</p>
<p className="rt-tooltip__row rt-tooltip__row--avg">
Avg: <strong>{d?.avg}ms</strong>
</p>
<p className="rt-tooltip__row rt-tooltip__row--min">
Min: <strong>{d?.min}ms</strong>
</p>
<p className="rt-tooltip__row rt-tooltip__row--max">
Max: <strong>{d?.max}ms</strong>
</p>
<p className="rt-tooltip__checks">{d?.checks} checks</p>
</div>
);
};
return (
<div className="response-time-chart" ref={chartRef}>
<div className="response-time-chart__header">
<div className="response-time-chart__heading">
<div className="response-time-chart__title-group">
<h3>Response Time</h3>
<span className="response-time-chart__range">
{granularity === 'hour' ? 'Hourly averages' : 'Daily averages'}
</span>
</div>
{stats && (
<div className="rt-stats-bar">
{stats.min != null && (
<span className="rt-stat-chip">
<span className="rt-stat-chip__label">Min</span>
<strong>{Math.round(stats.min)}ms</strong>
</span>
)}
{stats.avg != null && (
<span className="rt-stat-chip">
<span className="rt-stat-chip__label">Avg</span>
<strong>{Math.round(stats.avg)}ms</strong>
</span>
)}
{stats.max != null && (
<span className="rt-stat-chip">
<span className="rt-stat-chip__label">Max</span>
<strong>{Math.round(stats.max)}ms</strong>
</span>
)}
</div>
)}
</div>
<div className="response-time-chart__presets">
{PRESETS.map((p) => (
<button
key={p.label}
className={`rt-preset-btn${
preset.label === p.label ? ' rt-preset-btn--active' : ''
}`}
onClick={() => setPreset(p)}
type="button"
>
{p.label}
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={chartData} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradAvg" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.5} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradMax" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(255,255,255,0.06)"
vertical={false}
/>
<XAxis
dataKey="time"
stroke="var(--text-tertiary, #94a3b8)"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="var(--text-tertiary, #94a3b8)"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}ms`}
width={55}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: '12px', paddingTop: '8px' }}
formatter={(value) =>
value === 'avg'
? 'Avg'
: value === 'max'
? 'Max'
: value === 'min'
? 'Min'
: value
}
/>
<Area
type="monotone"
dataKey="max"
stroke="#f59e0b"
strokeWidth={1}
strokeDasharray="4 3"
fill="url(#gradMax)"
dot={false}
name="max"
/>
<Area
type="monotone"
dataKey="avg"
stroke="#6366f1"
strokeWidth={2}
fill="url(#gradAvg)"
dot={false}
activeDot={{ r: 5 }}
name="avg"
/>
<Area
type="monotone"
dataKey="min"
stroke="#10b981"
strokeWidth={1}
fill="none"
dot={false}
name="min"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export default ResponseTimeChart;

View File

@@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
import { format, parseISO } from 'date-fns';
import { getEndpointShardEvents } from '../../../shared/api/publicApi';
import '../../../styles/features/status/ShardEvents.css';
function formatDuration(mins) {
if (mins < 1) return '< 1m';
if (mins < 60) return `${mins}m`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function ShardEvents({ endpointId, days = 7 }) {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!endpointId) { setLoading(false); return; }
let cancelled = false;
setLoading(true);
getEndpointShardEvents(endpointId, days)
.then(res => { if (!cancelled) setEvents(res.data.events || []); })
.catch(() => {})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [endpointId, days]);
if (loading || events.length === 0) return null;
return (
<div className="shard-events">
<p className="shard-events__title">Shard Reconnect Events Last {days} days</p>
<div className="shard-events__list">
{events.map((e, i) => (
<div key={i} className={`shard-event ${e.ongoing ? 'shard-event--ongoing' : ''}`}>
<div className="shard-event__dot" />
<div className="shard-event__body">
<div className="shard-event__top">
<span className="shard-event__name">{e.shard_name}</span>
{e.ongoing
? <span className="shard-event__badge shard-event__badge--ongoing">Ongoing</span>
: <span className="shard-event__badge shard-event__badge--resolved">Resolved</span>
}
<span className="shard-event__duration">{formatDuration(e.duration_mins)}</span>
</div>
<div className="shard-event__times">
<span> {format(parseISO(e.went_down), 'MMM d HH:mm')}</span>
{e.came_back && <span> {format(parseISO(e.came_back), 'MMM d HH:mm')}</span>}
</div>
</div>
</div>
))}
</div>
</div>
);
}
export default ShardEvents;

View File

@@ -0,0 +1,53 @@
import React, { useState, useEffect } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
import { getEndpointUptime } from '../../../shared/api/publicApi';
function ShardUptime({ shardId, days = 7 }) {
const [data, setData] = useState(null);
useEffect(() => {
getEndpointUptime(shardId, days)
.then(r => setData(r.data))
.catch(() => {});
}, [shardId, days]);
if (!data) return null;
const uptime = parseFloat(data.uptime);
const color = uptime >= 99 ? '#10b981' : uptime >= 95 ? '#f59e0b' : '#ef4444';
const chartData = [
{ value: uptime },
{ value: 100 - uptime },
];
return (
<div className="shard-uptime">
<div className="shard-uptime__donut">
<ResponsiveContainer width={48} height={48}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={14}
outerRadius={22}
startAngle={90}
endAngle={-270}
paddingAngle={1}
dataKey="value"
>
<Cell fill={color} />
<Cell fill="rgba(255,255,255,0.06)" />
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<div className="shard-uptime__label">
<span className="shard-uptime__pct" style={{ color }}>{uptime.toFixed(1)}%</span>
<span className="shard-uptime__sub">{days}d uptime</span>
</div>
</div>
);
}
export default ShardUptime;

View File

@@ -0,0 +1,21 @@
import React from 'react';
function getStatusVariant(overallStatus) {
if (overallStatus === 'All Systems Operational') return 'up';
if (overallStatus.includes('Outage')) return 'down';
return 'degraded';
}
export default function StatusOverviewCard({ overallStatus }) {
return (
<div className={`card status-overview status-overview--${getStatusVariant(overallStatus)}`}>
<div className="status-overview__inner">
<div className="status-overview__indicator" />
<div>
<h2 className="status-overview__title">{overallStatus}</h2>
<p className="last-updated">Last updated: {new Date().toLocaleTimeString()}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import React, { useState, useEffect } from 'react';
import { ChevronDown, ChevronRight, CheckCircle, AlertCircle, ZapOff, ExternalLink } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import '../../../styles/features/status/StatusPageCategories.css';
import { getStoredJSON, setStoredJSON } from '../../../shared/utils/storage';
import { getUptimeColorValue, formatUptimePercent, getStatusLabel } from '../../../shared/utils/status';
function StatusPageCategories({ categories, endpoints }) {
const [collapsedState, setCollapsedState] = useState({});
const navigate = useNavigate();
useEffect(() => {
setCollapsedState(getStoredJSON('categoryCollapseState', {}));
}, []);
const toggleCategory = (categoryId) => {
const isCollapsed = !collapsedState[categoryId];
const newState = { ...collapsedState, [categoryId]: isCollapsed };
setCollapsedState(newState);
setStoredJSON('categoryCollapseState', newState);
};
const getCategoryStatus = (categoryId) => {
const categoryEndpoints = endpoints.filter(ep => ep.group_id === categoryId);
if (categoryEndpoints.length === 0) return 'unknown';
const statuses = categoryEndpoints.map(ep => ep.latest?.status || 'unknown');
if (statuses.includes('down')) return 'down';
if (statuses.includes('degraded')) return 'degraded';
return 'up';
};
const getCategoryStatusIcon = (status) => {
switch (status) {
case 'up': return <CheckCircle size={13} className="cat-status-icon cat-status-icon--up" />;
case 'degraded': return <AlertCircle size={13} className="cat-status-icon cat-status-icon--degraded" />;
case 'down': return <ZapOff size={13} className="cat-status-icon cat-status-icon--down" />;
default: return <AlertCircle size={13} className="cat-status-icon cat-status-icon--unknown" />;
}
};
const renderServiceRow = (endpoint) => {
const status = endpoint.latest?.status || 'unknown';
const uptime = formatUptimePercent(endpoint.uptime_30d, 1);
return (
<div
key={endpoint.id}
className={`svc-row svc-row--${status}`}
onClick={() => navigate(`/endpoint/${endpoint.id}`)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && navigate(`/endpoint/${endpoint.id}`)}
>
<div className={`svc-row__accent svc-row__accent--${status}`} />
<div className="svc-row__main">
<div className="svc-row__left">
<span className="svc-row__name">{endpoint.name}</span>
<span className="svc-row__url">{endpoint.url}</span>
</div>
<div className="svc-row__right">
{endpoint.latest?.response_time != null && (
<span className="svc-row__rt">{endpoint.latest.response_time}ms</span>
)}
{uptime && (
<span className="svc-row__uptime" style={{ color: getUptimeColorValue(endpoint.uptime_30d) }}>
{uptime} <span className="svc-row__uptime-period">30d</span>
</span>
)}
<span className={`svc-row__badge svc-row__badge--${status}`}>
{getStatusLabel(status)}
</span>
<ExternalLink size={12} className="svc-row__link-icon" />
</div>
</div>
</div>
);
};
const renderCategory = (categoryId, name, categoryEndpoints, isUncategorized = false) => {
const isCollapsed = collapsedState[categoryId];
const status = isUncategorized ? 'unknown' : getCategoryStatus(categoryId);
const allUp = categoryEndpoints.every(ep => ep.latest?.status === 'up');
return (
<div key={categoryId} className="cat-section">
<button
className="cat-header"
onClick={() => toggleCategory(categoryId)}
aria-expanded={!isCollapsed}
>
<div className="cat-header__left">
{isCollapsed ? <ChevronRight size={15} className="cat-chevron" /> : <ChevronDown size={15} className="cat-chevron" />}
{!isUncategorized && getCategoryStatusIcon(status)}
<span className="cat-header__name">{name}</span>
<span className="cat-header__count">{categoryEndpoints.length}</span>
</div>
<div className="cat-header__right">
{!isCollapsed && allUp && (
<span className="cat-all-up">All Operational</span>
)}
</div>
</button>
{!isCollapsed && (
<div className="cat-body">
{categoryEndpoints.map(ep => renderServiceRow(ep))}
</div>
)}
</div>
);
};
return (
<div className="categories-container">
{categories && categories.length > 0 &&
categories.map(category => {
const categoryEndpoints = endpoints.filter(ep => ep.group_id === category.id);
if (categoryEndpoints.length === 0) return null;
return renderCategory(category.id, category.name, categoryEndpoints);
})
}
{(() => {
const uncategorized = endpoints.filter(ep => !ep.group_id);
if (uncategorized.length === 0) return null;
return renderCategory('uncategorized', 'Other Services', uncategorized, true);
})()}
</div>
);
}
export default StatusPageCategories;

View File

@@ -0,0 +1,158 @@
import React, { useState, useEffect, useRef } from 'react';
import { parseISO, format, subDays, eachDayOfInterval } from 'date-fns';
import { getEndpointHistory } from '../../../shared/api/publicApi';
import '../../../styles/features/status/UptimeHeatmap.css';
function cellColor(uptime, compact) {
if (uptime === null || uptime === undefined) return compact ? 'var(--heatmap-empty-compact, rgba(255,255,255,0.04))' : 'var(--heatmap-empty, rgba(255,255,255,0.06))';
if (uptime >= 99) return '#10b981';
if (uptime >= 95) return '#f59e0b';
return '#ef4444';
}
function UptimeHeatmap({ endpointId, days = 90, compact = false }) {
const [data, setData] = useState(null); // map: dateStr → { uptime, checks }
const [loading, setLoading] = useState(true);
const [tooltip, setTooltip] = useState(null); // { text, x, y }
const containerRef = useRef(null);
useEffect(() => {
if (!endpointId) { setLoading(false); return; }
let cancelled = false;
setLoading(true);
getEndpointHistory(endpointId, days)
.then(res => {
if (cancelled) return;
const map = {};
for (const row of (res.data.data || [])) {
map[row.date] = { uptime: row.uptime, checks: row.checks };
}
setData(map);
})
.catch(() => setData({}))
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [endpointId, days]);
if (loading) return compact ? null : <div className="heatmap-loading">Loading history</div>;
// Build the full list of days (oldest → newest, padded to full weeks)
const today = new Date();
const oldest = subDays(today, days - 1);
const allDays = eachDayOfInterval({ start: oldest, end: today });
// Pad the front so the first day falls on Monday (isoWeekday 1)
const startPad = (allDays[0].getDay() + 6) % 7; // Mon=0
const cells = [
...Array(startPad).fill(null),
...allDays.map(d => format(d, 'yyyy-MM-dd')),
];
// Split into weeks (columns)
const weeks = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
// Month labels for full view
const monthLabels = [];
if (!compact) {
weeks.forEach((week, wi) => {
const firstReal = week.find(Boolean);
if (!firstReal) return;
const d = parseISO(firstReal);
const label = format(d, 'MMM');
if (wi === 0 || (d.getDate() <= 7 && monthLabels[monthLabels.length - 1]?.label !== label)) {
monthLabels.push({ wi, label });
}
});
}
const cellSize = compact ? 6 : 12;
const gap = compact ? 2 : 3;
return (
<div className={`uptime-heatmap ${compact ? 'uptime-heatmap--compact' : ''}`} ref={containerRef}>
{!compact && (
<div className="heatmap-header">
<span className="heatmap-title">90-Day History</span>
<div className="heatmap-legend">
<span>Less</span>
{[null, 100, 97, 90].map((v, i) => (
<div key={i} className="heatmap-legend-cell" style={{ background: cellColor(v, false) }} />
))}
<span>More</span>
</div>
</div>
)}
{!compact && (
<div className="heatmap-months" style={{ paddingLeft: `${cellSize + gap}px` }}>
{monthLabels.map(({ wi, label }) => (
<span
key={wi}
className="heatmap-month-label"
style={{ left: `${wi * (cellSize + gap)}px` }}
>
{label}
</span>
))}
</div>
)}
<div className="heatmap-grid" style={{ gap: `${gap}px` }}>
{/* Day-of-week labels */}
{!compact && (
<div className="heatmap-dow" style={{ gap: `${gap}px`, width: `${cellSize}px` }}>
{['M','','W','','F','',''].map((l, i) => (
<div key={i} className="heatmap-dow-label" style={{ height: `${cellSize}px` }}>{l}</div>
))}
</div>
)}
{weeks.map((week, wi) => (
<div key={wi} className="heatmap-col" style={{ gap: `${gap}px` }}>
{week.map((dateStr, di) => {
if (!dateStr) {
return <div key={di} className="heatmap-cell heatmap-cell--empty" style={{ width: cellSize, height: cellSize }} />;
}
const entry = data?.[dateStr];
const uptime = entry?.uptime ?? null;
const checks = entry?.checks ?? 0;
const color = cellColor(uptime, compact);
return (
<div
key={di}
className="heatmap-cell"
style={{ width: cellSize, height: cellSize, background: color }}
onMouseEnter={e => {
if (compact) return;
const rect = containerRef.current?.getBoundingClientRect();
const cr = e.currentTarget.getBoundingClientRect();
setTooltip({
text: uptime !== null ? `${dateStr}: ${uptime}% uptime (${checks} checks)` : `${dateStr}: no data`,
x: cr.left - (rect?.left ?? 0) + cellSize / 2,
y: cr.top - (rect?.top ?? 0) - 8,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
);
})}
</div>
))}
</div>
{tooltip && (
<div
className="heatmap-tooltip"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.text}
</div>
)}
</div>
);
}
export default UptimeHeatmap;

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { getEndpointUptime } from '../../../shared/api/publicApi';
import '../../../styles/features/status/UptimeStats.css';
function UptimeStats({ endpointId, timeframe = 30, chartHeight = 170 }) {
const [uptimeData, setUptimeData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUptime();
}, [endpointId, timeframe]);
async function fetchUptime() {
try {
setLoading(true);
const response = await getEndpointUptime(endpointId, timeframe);
setUptimeData(response.data);
setError(null);
} catch (err) {
setError('Failed to load uptime data');
console.error(err);
} finally {
setLoading(false);
}
}
if (loading) return <div className="uptime-loading">Loading uptime...</div>;
if (error) return <div className="uptime-error">{error}</div>;
if (!uptimeData) return null;
const uptime = parseFloat(uptimeData.uptime);
const chartData = [
{ name: 'Uptime', value: uptime, fill: '#10b981' },
{ name: 'Downtime', value: 100 - uptime, fill: '#ef4444' }
];
const getUptimeColor = (percentage) => {
if (percentage >= 99.9) return '#10b981';
if (percentage >= 99) return '#f59e0b';
return '#ef4444';
};
return (
<div className="uptime-stats">
<div className="uptime-chart" style={{ height: `${chartHeight}px` }}>
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={chartHeight < 180 ? 42 : 60}
outerRadius={chartHeight < 180 ? 62 : 90}
paddingAngle={2}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value) => `${value.toFixed(2)}%`} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="uptime-details">
<div className="uptime-percentage">
<div className="uptime-value" style={{ color: getUptimeColor(uptime) }}>
{uptime.toFixed(2)}%
</div>
<div className="uptime-label">{timeframe}d Uptime</div>
</div>
<div className="uptime-info">
<div className="info-item">
<span className="info-label">Successful Checks:</span>
<span className="info-value">{uptimeData.ups}</span>
</div>
<div className="info-item">
<span className="info-label">Total Checks:</span>
<span className="info-value">{uptimeData.total}</span>
</div>
<div className="info-item">
<span className="info-label">Downtime Events:</span>
<span className="info-value">{uptimeData.total - uptimeData.ups}</span>
</div>
</div>
</div>
</div>
);
}
export default UptimeStats;

View File

@@ -0,0 +1,8 @@
export { default as EndpointDetailView } from './EndpointDetailView';
export { default as ResponseTimeChart } from './ResponseTimeChart';
export { default as UptimeHeatmap } from './UptimeHeatmap';
export { default as UptimeStats } from './UptimeStats';
export { default as ShardEvents } from './ShardEvents';
export { default as ShardUptime } from './ShardUptime';
export { default as StatusPageCategories } from './StatusPageCategories';
export { default as StatusOverviewCard } from './StatusOverviewCard';

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import {
getPublicEndpoints,
getPublicIncidents,
getPublicMaintenance,
getPublicEndpointById,
} from '../../../shared/api/publicApi';
import { getSettings } from '../../../shared/api/authApi';
export default function useStatusData(socket, selectedEndpoint) {
const [endpoints, setEndpoints] = useState([]);
const [categories, setCategories] = useState([]);
const [incidents, setIncidents] = useState([]);
const [maintenance, setMaintenance] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [endpointDetails, setEndpointDetails] = useState(null);
const [typeStats, setTypeStats] = useState(null);
const [pingStats, setPingStats] = useState(null);
const [siteTitle, setSiteTitle] = useState('Status Page');
useEffect(() => {
async function fetchData() {
try {
setError(null);
const [endpointsRes, incidentsRes, maintenanceRes, settingsRes] = await Promise.all([
getPublicEndpoints(),
getPublicIncidents(),
getPublicMaintenance(),
getSettings().catch(() => ({ data: { title: 'Status Page' } })),
]);
if (Array.isArray(endpointsRes.data)) {
setEndpoints(endpointsRes.data);
setCategories([]);
} else {
setEndpoints(endpointsRes.data.endpoints || []);
setCategories(endpointsRes.data.categories || []);
}
setIncidents(Array.isArray(incidentsRes.data) ? incidentsRes.data : []);
setMaintenance(Array.isArray(maintenanceRes.data) ? maintenanceRes.data : []);
if (settingsRes.data?.title) {
setSiteTitle(settingsRes.data.title);
}
} catch (err) {
setError('Failed to load status page');
// eslint-disable-next-line no-console
console.error(err);
} finally {
setLoading(false);
}
}
fetchData();
if (socket) {
socket.on('checkResult', (data) => {
setEndpoints((prev) =>
prev.map((ep) =>
ep.id === data.endpoint_id
? {
...ep,
latest: {
status: data.status,
response_time: data.responseTime,
checked_at: data.checked_at,
},
}
: ep
)
);
});
socket.on('incidentCreated', (incident) => {
setIncidents((prev) => {
if (prev.find((i) => i.id === incident.id)) return prev;
return [incident, ...prev];
});
});
socket.on('incidentUpdated', (incident) => {
setIncidents((prev) => prev.map((i) => (i.id === incident.id ? { ...i, ...incident } : i)));
});
socket.on('incidentResolved', (incident) => {
setIncidents((prev) => prev.map((i) => (i.id === incident.id ? { ...i, ...incident } : i)));
});
}
const interval = setInterval(fetchData, 30000);
return () => {
clearInterval(interval);
if (socket) {
socket.off('checkResult');
socket.off('incidentCreated');
socket.off('incidentUpdated');
socket.off('incidentResolved');
}
};
}, [socket]);
useEffect(() => {
if (!selectedEndpoint) {
setEndpointDetails(null);
setTypeStats(null);
setPingStats(null);
return;
}
async function fetchEndpointDetails() {
try {
const response = await getPublicEndpointById(selectedEndpoint);
const endpointData = response.data.endpoint || response.data;
setEndpointDetails(endpointData);
setTypeStats(response.data.typeStats || null);
setPingStats(response.data.pingStats || null);
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to load endpoint details:', err);
}
}
fetchEndpointDetails();
}, [selectedEndpoint]);
return {
endpoints,
categories,
incidents,
maintenance,
loading,
error,
endpointDetails,
typeStats,
pingStats,
siteTitle,
};
}

View File

@@ -0,0 +1,6 @@
import React from 'react';
import StatusPage from './StatusPage';
export default function EndpointPage({ socket }) {
return <StatusPage socket={socket} />;
}

View File

@@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTheme } from '../../../context/ThemeContext';
import { Sun, Moon, LayoutDashboard } from 'lucide-react';
import useStatusData from '../hooks/useStatusData';
import {
getOverallStatus,
getStatusClass,
getUptimeColorValue,
formatUptimePercent,
getStatusLabel,
} from '../../../shared/utils/status';
import '../../../styles/base/App.css';
import '../../../styles/features/status/StatusPage.css';
import { IncidentHistory, IncidentBanner, MaintenanceNotice } from '../../incidents/components';
import { LoadingSpinner } from '../../../shared/components';
import {
UptimeHeatmap,
StatusPageCategories,
ResponseTimeChart,
UptimeStats,
EndpointDetailView,
StatusOverviewCard,
} from '../components';
function StatusPage({ socket }) {
const [selectedEndpoint, setSelectedEndpoint] = useState(null);
const {
endpoints,
categories,
incidents,
maintenance,
loading,
error,
endpointDetails,
typeStats,
pingStats,
siteTitle,
} = useStatusData(socket, selectedEndpoint);
const { theme, toggleTheme } = useTheme();
const { endpointId: urlEndpointId } = useParams();
const navigate = useNavigate();
useEffect(() => {
if (urlEndpointId) {
const parsedId = parseInt(urlEndpointId, 10);
setSelectedEndpoint(Number.isNaN(parsedId) ? null : parsedId);
} else {
setSelectedEndpoint(null);
}
}, [urlEndpointId]);
const activeIncidents = incidents.filter((i) => !i.resolved_at);
const incidentEndpointIds = new Set(
activeIncidents.flatMap((i) => (i.endpoints || []).map((e) => e.id))
);
const currentOverallStatus = getOverallStatus(endpoints);
const buildRecentEvents = (endpoint) => {
if (!endpoint) return [];
const items = [];
const latest = endpoint.latest;
if (latest?.checked_at) {
const currentStatus = latest.status || 'unknown';
items.push({
status: currentStatus,
label:
currentStatus === 'up'
? 'Operational'
: currentStatus === 'down'
? 'Down'
: currentStatus === 'degraded'
? 'Degraded'
: 'Unknown',
time: new Date(latest.checked_at).toLocaleTimeString(),
});
}
return items;
};
const buildEndpointStats = () => {
const base = {};
if (typeStats) {
base.loss = typeStats.packet_loss;
base.avg = typeStats.avg_rt;
base.min = typeStats.min_rt;
base.jitter = typeStats.jitter;
base.max = typeStats.max_rt;
base.avgResponseTime = typeStats.avg_rt;
}
if (pingStats) {
base.loss = pingStats.packet_loss ?? base.loss;
base.avg = pingStats.avg_rt ?? base.avg;
base.min = pingStats.min_rt ?? base.min;
base.jitter = pingStats.jitter ?? base.jitter;
base.max = pingStats.max_rt ?? base.max;
base.avgResponseTime = pingStats.avg_rt ?? base.avgResponseTime;
}
return base;
};
if (loading) {
return (
<div className="loading-page">
<LoadingSpinner size="medium" text="Loading status data..." />
</div>
);
}
if (selectedEndpoint && endpointDetails) {
const endpoint = endpointDetails;
const endpointIncidents = incidents.filter((incident) =>
(incident.endpoints || []).some((e) => e.id === endpoint.id)
);
const endpointMaintenances = maintenance.filter((window) =>
(window.endpoints || []).some((e) => e.id === endpoint.id)
);
const responseChart = <ResponseTimeChart endpointId={selectedEndpoint} />;
const uptimeChart = <UptimeStats endpointId={selectedEndpoint} timeframe={30} />;
const historyHeatmap = <UptimeHeatmap endpointId={selectedEndpoint} days={90} />;
const recentEvents = buildRecentEvents(endpoint);
const endpointStats = buildEndpointStats();
const endpointUptime = {
percentage:
endpoint.uptime_30d != null
? Number(endpoint.uptime_30d)
: null,
totalChecks:
endpoint.total_checks_30d ??
endpoint.total_checks ??
endpoint.totalChecks ??
null,
successfulChecks:
endpoint.successful_checks_30d ??
endpoint.successfulChecks ??
null,
downtimeEvents:
endpoint.downtime_events_30d ??
null,
};
const normalizedEndpoint = {
...endpoint,
status: endpoint.latest?.status || endpoint.status || 'unknown',
responseTime: endpoint.latest?.response_time ?? endpoint.responseTime,
lastChecked:
endpoint.latest?.checked_at
? new Date(endpoint.latest.checked_at).toLocaleTimeString()
: endpoint.lastChecked,
lastCheckFormatted:
endpoint.latest?.checked_at
? new Date(endpoint.latest.checked_at).toLocaleTimeString()
: endpoint.lastCheckFormatted,
checkInterval: endpoint.interval ?? endpoint.checkInterval,
uptime30d: endpoint.uptime_30d,
totalChecks: endpoint.total_checks_30d ?? endpoint.totalChecks,
};
return (
<EndpointDetailView
selectedEndpoint={normalizedEndpoint}
endpointIncidents={endpointIncidents}
endpointMaintenances={endpointMaintenances}
stats={endpointStats}
uptime={endpointUptime}
recentEvents={recentEvents}
responseChart={responseChart}
uptimeChart={uptimeChart}
historyHeatmap={historyHeatmap}
themeToggle={
<button
className="theme-toggle-btn"
onClick={toggleTheme}
type="button"
aria-label="Toggle theme"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
}
onBack={() => {
setSelectedEndpoint(null);
navigate('/');
}}
/>
);
}
return (
<div className="app status-page">
<div className="container">
<div className="header">
<h1>{siteTitle}</h1>
<div className="header-actions">
<button
onClick={toggleTheme}
className="theme-toggle-btn"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
type="button"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
<a href="/login" className="admin-link">
<LayoutDashboard size={14} /> Admin
</a>
</div>
</div>
<StatusOverviewCard overallStatus={currentOverallStatus} />
{error && <div className="error">{error}</div>}
<MaintenanceNotice windows={maintenance} />
<IncidentBanner incidents={activeIncidents} />
{categories && categories.length > 0 ? (
<>
<p className="section-label">Services</p>
<StatusPageCategories categories={categories} endpoints={endpoints} />
</>
) : (
<>
<p className="section-label">Services</p>
<div className="grid">
{endpoints.map((endpoint) => {
const status = endpoint.latest?.status || 'unknown';
const uptime = formatUptimePercent(endpoint.uptime_30d, 1);
return (
<div
key={endpoint.id}
className={`card service-card service-card--${status} service-card--clickable`}
onClick={() => navigate(`/endpoint/${endpoint.id}`)}
>
<div
className={`service-card__accent service-card__accent--${status}`}
/>
<div className="service-card__body">
<div className="service-card__top">
<div className="service-card__title-group">
<h3 className="service-card__name">{endpoint.name}</h3>
<p className="service-url">{endpoint.url}</p>
</div>
<div className="service-card__badges">
{incidentEndpointIds.has(endpoint.id) && (
<span className="incident-impact-badge">Incident</span>
)}
{uptime && (
<span
className="uptime-30d-badge"
style={{ color: getUptimeColorValue(endpoint.uptime_30d) }}
>
{uptime}{' '}
<span className="uptime-30d-badge__period">30d</span>
</span>
)}
<span className={`status-badge ${getStatusClass(status)}`}>
{getStatusLabel(status)}
</span>
</div>
</div>
{endpoint.latest && (
<div className="service-card__meta">
<span className="service-meta-item">
<span className="service-meta-item__label">Response</span>
<strong>{endpoint.latest.response_time}ms</strong>
</span>
<span className="service-meta-item">
<span className="service-meta-item__label">Checked</span>
<strong>
{new Date(endpoint.latest.checked_at).toLocaleTimeString()}
</strong>
</span>
</div>
)}
<UptimeHeatmap endpointId={endpoint.id} days={90} compact />
</div>
</div>
);
})}
</div>
</>
)}
{endpoints.length === 0 && !loading && (
<div className="card empty-state">
<div className="empty-state__icon">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<p className="empty-state__text">No services configured yet.</p>
</div>
)}
<div className="incident-history-section">
<IncidentHistory incidents={incidents} />
</div>
</div>
</div>
);
}
export default StatusPage;

View File

@@ -0,0 +1,2 @@
export { default as StatusPage } from './StatusPage';
export { default as EndpointPage } from './EndpointPage';

25
frontend/src/index.js Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './styles/tokens/theme.css';
import './styles/base/index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Remove initial HTML loader after React app mounts
setTimeout(() => {
const loader = document.getElementById('initial-loader');
if (loader) {
loader.classList.add('hidden');
setTimeout(() => {
if (loader.parentNode) {
loader.parentNode.removeChild(loader);
}
}, 300);
}
}, 100);

View File

@@ -0,0 +1,125 @@
import client from './client';
export function getAdminUsers() {
return client.get('/api/admin/users');
}
export function createAdminUser(payload) {
return client.post('/api/admin/users', payload);
}
export function deleteAdminUser(userId) {
return client.delete(`/api/admin/users/${userId}`);
}
export function getAdminCategories() {
return client.get('/api/admin/categories');
}
export function createAdminCategory(payload) {
return client.post('/api/admin/categories', payload);
}
export function updateAdminCategory(id, payload) {
return client.put(`/api/admin/categories/${id}`, payload);
}
export function deleteAdminCategory(id) {
return client.delete(`/api/admin/categories/${id}`);
}
export function reorderAdminCategories(order) {
return client.put('/api/admin/categories/reorder', { order });
}
export function createEndpoint(payload) {
return client.post('/api/admin/endpoints', payload);
}
export function updateEndpoint(id, payload) {
return client.put(`/api/admin/endpoints/${id}`, payload);
}
export function deleteEndpoint(id) {
return client.delete(`/api/admin/endpoints/${id}`);
}
export function reorderEndpoints(updates) {
return client.put('/api/admin/endpoints/reorder', { updates });
}
export function getAdminApiKeys() {
return client.get('/api/admin/api-keys');
}
export function createAdminApiKey(payload) {
return client.post('/api/admin/api-keys', payload);
}
export function revokeAdminApiKey(id) {
return client.delete(`/api/admin/api-keys/${id}`);
}
export function createIncident(payload) {
return client.post('/api/admin/incidents', payload);
}
export function updateIncident(id, payload) {
return client.put(`/api/admin/incidents/${id}`, payload);
}
export function deleteIncident(id) {
return client.delete(`/api/admin/incidents/${id}`);
}
export function addIncidentUpdate(id, payload) {
return client.post(`/api/admin/incidents/${id}/updates`, payload);
}
export function resolveIncident(id, payload) {
return client.patch(`/api/admin/incidents/${id}/resolve`, payload);
}
export function reopenIncident(id, payload) {
return client.post(`/api/admin/incidents/${id}/reopen`, payload);
}
export function saveIncidentPostMortem(id, post_mortem) {
return client.put(`/api/admin/incidents/${id}/post-mortem`, { post_mortem });
}
export function createMaintenance(payload) {
return client.post('/api/admin/maintenance', payload);
}
export function deleteMaintenance(id) {
return client.delete(`/api/admin/maintenance/${id}`);
}
export function getNotificationDefaults() {
return client.get('/api/admin/notifications/defaults');
}
export function saveNotificationDefaults(payload) {
return client.put('/api/admin/notifications/defaults', payload);
}
export function getNotificationHealth() {
return client.get('/api/admin/notifications/health');
}
export function getNotificationDeliveries(params = {}) {
return client.get('/api/admin/notifications/deliveries', { params });
}
export function getExtraRecipients() {
return client.get('/api/admin/notifications/extra-recipients');
}
export function createExtraRecipient(payload) {
return client.post('/api/admin/notifications/extra-recipients', payload);
}
export function deleteExtraRecipient(id) {
return client.delete(`/api/admin/notifications/extra-recipients/${id}`);
}

View File

@@ -0,0 +1,53 @@
import client from './client';
export function checkSetup() {
return client.get('/api/auth/setup', { skipAuth: true });
}
export function login(payload) {
return client.post('/api/auth/login', payload, { skipAuth: true });
}
export function setup(payload) {
return client.post('/api/auth/setup', payload, { skipAuth: true });
}
export function getProfile() {
return client.get('/api/auth/profile');
}
export function logout() {
return client.post('/api/auth/logout', {});
}
export function getSettings() {
return client.get('/api/auth/settings', { skipAuth: true });
}
export function getAdminSettings() {
return client.get('/api/auth/settings/admin');
}
export function saveSettings(payload) {
return client.post('/api/admin/settings', payload);
}
export function sendTestEmail(payload) {
return client.post('/api/admin/settings/test-email', payload);
}
export function getNotificationPreferences() {
return client.get('/api/notifications/preferences');
}
export function saveNotificationPreferences(payload) {
return client.put('/api/notifications/preferences', payload);
}
export function changePassword(payload) {
return client.post('/api/auth/change-password', payload);
}
export function updateProfile(payload) {
return client.put('/api/auth/profile', payload);
}

View File

@@ -0,0 +1,22 @@
import axios from 'axios';
const client = axios.create();
client.interceptors.request.use((config) => {
const next = { ...config };
if (!next.headers) {
next.headers = {};
}
if (!next.skipAuth) {
const token = localStorage.getItem('token');
if (token && !next.headers.Authorization) {
next.headers.Authorization = `Bearer ${token}`;
}
}
return next;
});
export default client;

View File

@@ -0,0 +1,49 @@
import client from './client';
export function getPublicEndpoints() {
return client.get('/api/public/endpoints', { skipAuth: true });
}
export function getPublicIncidents() {
return client.get('/api/public/incidents', { skipAuth: true });
}
export function getPublicIncidentById(incidentId) {
return client.get(`/api/public/incidents/${incidentId}`, { skipAuth: true });
}
export function getPublicMaintenance() {
return client.get('/api/public/maintenance', { skipAuth: true });
}
export function getPublicEndpointById(endpointId) {
return client.get(`/api/public/endpoints/${endpointId}`, { skipAuth: true });
}
export function getEndpointHistory(endpointId, days) {
return client.get(`/api/public/endpoints/${endpointId}/history`, {
params: { days },
skipAuth: true,
});
}
export function getEndpointUptime(endpointId, days) {
return client.get(`/api/public/endpoints/${endpointId}/uptime`, {
params: { days },
skipAuth: true,
});
}
export function getEndpointResponseTimes(endpointId, params) {
return client.get(`/api/public/endpoints/${endpointId}/response-times`, {
params,
skipAuth: true,
});
}
export function getEndpointShardEvents(endpointId, days) {
return client.get(`/api/public/endpoints/${endpointId}/shard-events`, {
params: { days },
skipAuth: true,
});
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import '../../styles/shared/LoadingSpinner.css';
function LoadingSpinner({ size = 'medium', text = 'Loading...' }) {
return (
<div className={`loading-spinner loading-spinner--${size}`}>
<div className="spinner-ring">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
{text && <p className="loading-spinner__text">{text}</p>}
</div>
);
}
export default LoadingSpinner;

View File

@@ -0,0 +1 @@
export { default as LoadingSpinner } from './LoadingSpinner';

View File

@@ -0,0 +1,13 @@
export const STATUS_LABELS = {
up: 'Operational',
degraded: 'Degraded',
down: 'Down',
unknown: 'Unknown',
};
export const INCIDENT_STATUS_LABELS = {
investigating: 'Investigating',
identified: 'Identified',
monitoring: 'Monitoring',
resolved: 'Resolved',
};

View File

@@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
export default function usePolling(callback, delay, deps = []) {
const intervalRef = useRef(null);
useEffect(() => {
callback();
intervalRef.current = setInterval(callback, delay);
return () => {
clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return intervalRef;
}

View File

@@ -0,0 +1,19 @@
export function formatDuration(startTime, resolvedAt) {
if (!resolvedAt) return 'Ongoing';
const minutes = Math.round((new Date(resolvedAt) - new Date(startTime)) / 60000);
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
return `${Math.round(minutes / 1440)}d`;
}
export function formatResponseTime(ms) {
if (ms == null || Number.isNaN(Number(ms))) return '-';
return `${Math.round(Number(ms))}ms`;
}
export function formatPercent(value, fixed = 2) {
if (value == null || Number.isNaN(Number(value))) return '-';
return `${Number(value).toFixed(fixed)}%`;
}

View File

@@ -0,0 +1,41 @@
import { STATUS_LABELS } from '../constants/status';
export function getOverallStatus(endpoints = []) {
if (endpoints.length === 0) return 'All Systems Operational';
const downCount = endpoints.filter((ep) => ep.latest?.status === 'down').length;
const total = endpoints.length;
if (downCount === 0) {
if (endpoints.some((ep) => ep.latest?.status !== 'up')) return 'Degraded Performance';
return 'All Systems Operational';
}
if (downCount === total) return 'Complete Outage';
if (downCount / total > 0.5) return 'Major Outage';
return 'Minor Outage';
}
export function getStatusClass(status) {
if (status === 'up') return 'status-up';
if (status === 'down') return 'status-down';
if (status === 'degraded') return 'status-degraded';
return 'status-degraded';
}
export function getStatusLabel(status) {
return STATUS_LABELS[status] || STATUS_LABELS.unknown;
}
export function getUptimeColorValue(pct) {
if (pct === null || pct === undefined) return 'var(--text-tertiary)';
if (pct >= 99) return 'var(--status-up)';
if (pct >= 95) return 'var(--status-degraded)';
return 'var(--status-down)';
}
export function formatUptimePercent(pct, decimals = 1) {
if (pct === null || pct === undefined) return null;
if (pct % 1 === 0) return `${pct}%`;
return `${parseFloat(Number(pct).toFixed(decimals))}%`;
}

View File

@@ -0,0 +1,18 @@
export function getStoredJSON(key, fallback) {
try {
const value = localStorage.getItem(key);
if (!value) return fallback;
return JSON.parse(value);
} catch {
return fallback;
}
}
export function setStoredJSON(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
export function getStoredValue(key, fallback = null) {
const value = localStorage.getItem(key);
return value ?? fallback;
}

View File

@@ -0,0 +1,183 @@
.app {
min-height: 100vh;
background: var(--dark-bg);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: var(--dark-card);
padding: 14px 20px;
border-radius: 12px;
border: 1px solid var(--dark-border);
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
color: var(--accent-primary);
margin: 0;
flex: 1;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.theme-toggle-btn {
padding: 10px 14px;
background: var(--dark-surface);
color: var(--accent-primary);
border: 1px solid var(--dark-border);
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle-btn:hover {
background: var(--dark-border);
transform: translateY(-2px);
}
.back-btn {
padding: 8px 16px;
background: var(--dark-surface);
color: var(--text-primary);
border: 1px solid var(--dark-border);
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
font-family: inherit;
display: inline-flex;
align-items: center;
gap: 6px;
}
.back-btn:hover {
background: var(--dark-border);
transform: translateY(-2px);
}
.admin-link {
padding: 8px 16px;
background: var(--accent-primary);
color: white;
border-radius: 8px;
text-decoration: none;
transition: all 0.3s;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
}
.admin-link:hover {
background: var(--accent-primary-dark);
color: white;
transform: translateY(-2px);
}
.header-nav {
display: flex;
gap: 15px;
align-items: center;
}
.header-nav a, .header-nav button {
padding: 10px 20px;
background: var(--accent-primary);
color: white;
border-radius: 8px;
transition: all 0.3s;
border: none;
cursor: pointer;
font-weight: 500;
}
.header-nav a:hover, .header-nav button:hover {
background: var(--accent-primary-dark);
transform: translateY(-2px);
}
.card {
background: var(--dark-card);
border-radius: 12px;
padding: 14px 18px;
border: 1px solid var(--dark-border);
transition: all 0.3s;
}
.card:hover {
border-color: var(--dark-border);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.status-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-up {
background: rgba(16, 185, 129, 0.15);
color: var(--status-up);
}
.status-down {
background: rgba(239, 68, 68, 0.15);
color: var(--status-down);
}
.status-degraded {
background: rgba(245, 158, 11, 0.15);
color: var(--status-degraded);
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.error {
background: rgba(239, 68, 68, 0.1);
color: var(--status-down);
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.success {
background: rgba(16, 185, 129, 0.1);
color: var(--status-up);
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid rgba(16, 185, 129, 0.3);
}

View File

@@ -0,0 +1,54 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
a {
text-decoration: none;
color: inherit;
}
button {
cursor: pointer;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
input, textarea {
font-family: inherit;
padding: 10px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,557 @@
.admin-incidents {
display: flex;
flex-direction: column;
gap: 12px;
}
.ai-loading {
color: var(--text-tertiary);
font-size: 14px;
padding: 30px 0;
text-align: center;
}
/* Banners */
.ai-banner {
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
}
.ai-error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.ai-success {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
/* Toolbar */
.ai-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.ai-filters {
display: flex;
gap: 8px;
}
.ai-filter-btn {
padding: 7px 14px;
background: var(--dark-surface);
border: 1px solid var(--dark-border);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
font-family: inherit;
}
.ai-filter-btn:hover {
color: var(--text-primary);
border-color: var(--accent-primary);
}
.ai-filter-btn.active {
background: rgba(99, 102, 241, 0.12);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.ai-count-badge {
background: #ef4444;
color: white;
border-radius: 10px;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
/* Create/Edit form card */
.ai-form-card {
padding: 14px 16px;
}
.ai-form-card h3 {
margin: 0 0 12px;
color: var(--accent-primary);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* Incident form fixed 4-col grid so Title+Sev+Status sit on one row */
.ai-form-grid {
grid-template-columns: 2fr 1fr 1fr;
}
.ai-form-grid .ai-span-2 {
grid-column: 1 / -1;
}
.ai-span-2 {
grid-column: span 2;
}
/* Maintenance form 5-col single-row layout */
.maint-form-grid {
grid-template-columns: 2fr 1fr 1.2fr 1fr 1fr;
}
.maint-form-grid .maint-title { /* stays in first col, already 2fr */ }
@media (max-width: 900px) {
.maint-form-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
.ai-form-grid { grid-template-columns: 1fr; }
.ai-form-grid .ai-span-2 { grid-column: span 1; }
.ai-span-2 { grid-column: span 1; }
.maint-form-grid { grid-template-columns: 1fr; }
}
/* Endpoint chip multi-select */
.ai-endpoint-select {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
background: var(--dark-surface);
border: 1px solid var(--dark-border);
border-radius: 8px;
min-height: 38px;
}
.ai-ep-chip {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
background: var(--dark-card);
border: 1px solid var(--dark-border);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.ai-ep-chip input { display: none; }
.ai-ep-chip.selected {
background: rgba(99, 102, 241, 0.15);
border-color: var(--accent-primary);
color: var(--accent-primary);
font-weight: 600;
}
.ai-ep-chip:hover {
border-color: var(--accent-primary);
color: var(--text-primary);
}
/* Markdown textarea + preview */
.ai-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ai-label-row label { margin-bottom: 0; }
.ai-preview-toggle {
background: none;
border: 1px solid var(--dark-border);
border-radius: 6px;
color: var(--accent-primary);
cursor: pointer;
font-size: 12px;
padding: 3px 10px;
font-family: inherit;
transition: background 0.15s;
}
.ai-preview-toggle:hover {
background: rgba(99, 102, 241, 0.1);
}
.ai-textarea {
width: 100%;
padding: 8px 10px;
background: var(--dark-surface);
border: 1px solid var(--dark-border);
border-radius: 8px;
color: var(--text-primary);
font-family: 'SFMono-Regular', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
transition: border-color 0.2s;
}
.ai-textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.ai-md-preview {
min-height: 80px;
padding: 10px 14px;
background: var(--dark-bg-secondary);
border: 1px solid var(--dark-border);
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
.ai-md-preview p { margin: 0 0 6px; }
.ai-md-preview p:last-child { margin-bottom: 0; }
.ai-md-preview strong { color: var(--text-primary); }
.ai-md-preview code {
background: var(--dark-card);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
.ai-md-preview a { color: var(--accent-primary); }
.ai-md-preview ul, .ai-md-preview ol { padding-left: 18px; }
.ai-md-preview blockquote {
border-left: 3px solid var(--accent-primary);
margin: 6px 0;
padding: 2px 10px;
color: var(--text-tertiary);
font-style: italic;
}
/* Incident list */
.ai-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-empty {
text-align: center;
color: var(--text-tertiary);
padding: 30px !important;
font-size: 14px;
}
/* Single incident card */
.ai-incident-card {
border-radius: 10px;
border: 1px solid var(--dark-border);
overflow: hidden;
background: var(--dark-card);
transition: box-shadow 0.2s;
}
.ai-incident-card:hover { box-shadow: 0 3px 14px rgba(0,0,0,0.2); }
.ai-open { border-left: 4px solid #ef4444; }
.ai-resolved { border-left: 4px solid var(--text-tertiary); opacity: 0.85; }
.ai-incident-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
cursor: pointer;
gap: 12px;
}
.ai-incident-left {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
}
/* Severity dot */
.ai-sev-dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.sev-down { background: #ef4444; }
.sev-degraded { background: #f59e0b; }
.ai-incident-meta { flex: 1; min-width: 0; }
.ai-incident-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 4px;
}
.ai-incident-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
/* Status pills */
.ai-status-pill {
padding: 2px 7px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.pill-investigating { background: rgba(239,68,68,0.12); color: #ef4444; }
.pill-identified { background: rgba(245,158,11,0.12); color: #f59e0b; }
.pill-monitoring { background: rgba(59,130,246,0.12); color: #3b82f6; }
.pill-resolved { background: rgba(16,185,129,0.12); color: #10b981; }
.ai-auto-badge {
padding: 2px 7px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
background: rgba(148,163,184,0.1);
color: var(--text-tertiary);
border: 1px solid var(--dark-border);
}
.ai-incident-sub {
font-size: 12px;
color: var(--text-tertiary);
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.ai-resolved-label {
color: #10b981;
font-weight: 600;
}
.ai-affected {
font-style: italic;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-expand-icon {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
/* Expanded body */
.ai-incident-body {
padding: 0 16px 16px;
border-top: 1px solid var(--dark-border);
padding-top: 14px;
}
.ai-action-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.ai-btn-update,
.ai-btn-resolve,
.ai-btn-reopen,
.ai-btn-postmortem,
.ai-btn-edit,
.ai-btn-delete {
padding: 7px 14px;
border: none;
border-radius: 7px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.ai-btn-update { background: rgba(99,102,241,0.12); color: var(--accent-primary); }
.ai-btn-update:hover { background: rgba(99,102,241,0.22); }
.ai-btn-resolve { background: rgba(16,185,129,0.12); color: #10b981; }
.ai-btn-resolve:hover { background: rgba(16,185,129,0.22); }
.ai-btn-reopen { background: rgba(245,158,11,0.12); color: #f59e0b; }
.ai-btn-reopen:hover { background: rgba(245,158,11,0.22); }
.ai-btn-reopen--expired {
opacity: 0.4;
cursor: not-allowed;
background: var(--dark-surface);
color: var(--text-tertiary);
border: 1px solid var(--dark-border);
}
.ai-btn-reopen--expired:hover { background: var(--dark-surface); }
.ai-btn-postmortem { background: rgba(139,92,246,0.12); color: #8b5cf6; }
.ai-btn-postmortem:hover { background: rgba(139,92,246,0.22); }
.ai-btn-edit { background: var(--dark-surface); color: var(--text-secondary); border: 1px solid var(--dark-border); }
.ai-btn-edit:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.ai-btn-delete { background: rgba(239,68,68,0.1); color: #ef4444; }
.ai-btn-delete:hover { background: rgba(239,68,68,0.2); }
/* Sub-form (update / resolve) */
.ai-subform {
background: var(--dark-bg-secondary);
border: 1px solid var(--dark-border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.ai-subform h4 {
margin: 0 0 14px;
font-size: 13px;
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.ai-resolve-form { border-color: rgba(16,185,129,0.3); }
.ai-resolve-btn { background: #10b981 !important; }
.ai-resolve-btn:hover { background: #0ea472 !important; }
/* Timeline section */
.ai-timeline-section {
padding-top: 16px;
border-top: 1px solid var(--dark-border);
}
/* Maintenance */
.ai-maintenance {
border: 1px solid var(--dark-border);
border-radius: 10px;
overflow: hidden;
background: var(--dark-card);
}
.ai-maint-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
user-select: none;
transition: background 0.15s;
}
.ai-maint-header:hover { background: rgba(148,163,184,0.05); }
.ai-maint-body {
padding: 16px 18px;
border-top: 1px solid var(--dark-border);
}
.ai-maint-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--dark-border);
}
.ai-maint-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--dark-bg-secondary);
border: 1px solid var(--dark-border);
border-radius: 8px;
gap: 12px;
}
.ai-maint-row.maint-active { border-color: rgba(59,130,246,0.35); background: rgba(59,130,246,0.06); }
.ai-maint-row.maint-past { opacity: 0.5; }
.ai-maint-info { flex: 1; min-width: 0; }
.ai-maint-info strong { font-size: 13px; color: var(--text-primary); }
.ai-active-label {
margin-left: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: #3b82f6;
}
.ai-past-label {
margin-left: 8px;
font-size: 11px;
color: var(--text-tertiary);
}
.ai-maint-time {
margin: 3px 0 0;
font-size: 12px;
color: var(--text-tertiary);
}
/* Shared helpers */
.ai-muted { color: var(--text-tertiary); font-size: 12px; }
.ai-muted--maintenance-empty { padding: 12px 0 0; }
.btn-delete {
padding: 6px 12px;
background: rgba(239,68,68,0.1);
color: #ef4444;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
}
.btn-delete:hover { background: rgba(239,68,68,0.2); }
@media (max-width: 600px) {
.ai-toolbar { flex-direction: column; align-items: stretch; }
.ai-toolbar .btn-primary { width: 100%; }
.ai-action-bar { flex-direction: column; }
.ai-action-bar button { width: 100%; }
}

View File

@@ -0,0 +1,309 @@
/* ApiKeyManager.css */
.akm {
display: flex;
flex-direction: column;
gap: 16px;
}
.akm-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.akm-header__left {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
}
.akm-header__left h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.akm-count {
font-size: 12px;
background: var(--dark-border);
padding: 2px 8px;
border-radius: 10px;
color: var(--text-secondary);
}
.akm-description {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.akm-description code {
background: var(--dark-border);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
color: var(--accent-color, #00d4ff);
}
/* One-time key banner */
.akm-new-key-banner {
background: linear-gradient(135deg, rgba(0, 200, 100, 0.1), rgba(0, 200, 100, 0.05));
border: 1px solid rgba(0, 200, 100, 0.4);
border-radius: 8px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.akm-new-key-banner__header {
display: flex;
justify-content: space-between;
align-items: center;
color: #00c864;
font-size: 13px;
}
.akm-new-key-banner__dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 2px;
display: flex;
}
.akm-new-key-banner__key {
display: flex;
align-items: center;
gap: 10px;
background: var(--dark-surface, #0d1117);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
}
.akm-new-key-banner__key code {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #e2e8f0;
word-break: break-all;
flex: 1;
}
/* Copy buttons */
.akm-copy-btn {
background: var(--dark-border);
border: none;
border-radius: 4px;
cursor: pointer;
padding: 6px 8px;
color: var(--text-secondary);
display: flex;
align-items: center;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.akm-copy-btn:hover {
background: var(--accent-color, #00d4ff);
color: #000;
}
.akm-copy-btn-sm {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
color: var(--text-secondary);
display: flex;
align-items: center;
border-radius: 3px;
transition: color 0.15s;
}
.akm-copy-btn-sm:hover { color: var(--accent-color, #00d4ff); }
/* Form card */
.akm-form-card {
padding: 20px;
}
.akm-form-card h4 {
margin: 0 0 18px;
font-size: 0.9rem;
color: var(--text-primary);
}
/* Scope toggle */
.akm-scope-toggle {
display: flex;
gap: 8px;
margin-top: 6px;
}
.akm-scope-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border-radius: 6px;
border: 1px solid var(--dark-border);
background: var(--dark-surface);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
transition: border-color 0.15s, color 0.15s;
}
.akm-scope-btn:hover { border-color: var(--accent-color, #00d4ff); }
.akm-scope-btn.active {
border-color: var(--accent-color, #00d4ff);
color: var(--accent-color, #00d4ff);
background: rgba(0, 212, 255, 0.06);
}
/* Endpoint checkbox list */
.akm-endpoint-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 200px;
overflow-y: auto;
background: var(--dark-surface);
border: 1px solid var(--dark-border);
border-radius: 6px;
padding: 10px 12px;
}
.akm-endpoint-check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.akm-endpoint-check input {
accent-color: var(--accent-color, #00d4ff);
}
.akm-hint {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 0;
}
/* Table */
.akm-table-wrap {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--dark-border);
}
.akm-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.akm-table th {
text-align: left;
padding: 10px 14px;
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
border-bottom: 1px solid var(--dark-border);
background: var(--dark-surface);
}
.akm-table td {
padding: 10px 14px;
color: var(--text-primary);
border-bottom: 1px solid var(--dark-border);
vertical-align: middle;
}
.akm-table tr:last-child td { border-bottom: none; }
.akm-table tr:hover td { background: rgba(255,255,255,0.02); }
.akm-row--revoked td { opacity: 0.5; }
.akm-name { font-weight: 500; }
.akm-prefix-cell {
display: flex;
align-items: center;
gap: 6px;
}
.akm-prefix {
font-family: 'Courier New', monospace;
font-size: 12px;
background: var(--dark-surface);
padding: 2px 6px;
border-radius: 4px;
color: var(--accent-color, #00d4ff);
}
.akm-muted { color: var(--text-secondary); font-size: 12px; }
/* Badges */
.akm-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.akm-badge--global {
background: rgba(0, 212, 255, 0.1);
color: #00d4ff;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.akm-badge--endpoint {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.3);
}
/* Status dot */
.akm-status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
}
.akm-status-dot--active { background: #22c55e; }
.akm-status-dot--revoked { background: #ef4444; }
/* Docs hint at bottom */
.akm-docs {
font-size: 12px;
color: var(--text-secondary);
background: var(--dark-surface);
border: 1px solid var(--dark-border);
border-radius: 6px;
padding: 10px 14px;
line-height: 1.6;
}
.akm-docs code {
background: var(--dark-border);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
color: var(--accent-color, #00d4ff);
}

View File

@@ -0,0 +1,166 @@
.cat-mgr { display: flex; flex-direction: column; gap: 0; }
.cat-mgr__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 14px;
}
/* Create/edit form */
.cat-form {
background: var(--dark-bg-secondary);
border: 1px solid var(--dark-border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.cat-form .form-group { margin-bottom: 10px; }
.cat-form__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
/* List */
.cat-list { display: flex; flex-direction: column; gap: 6px; }
.cat-list__hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: right;
margin: 0 0 6px;
}
/* Individual item */
.cat-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--dark-card);
border: 1px solid var(--dark-border);
border-radius: 8px;
transition: background 0.15s, box-shadow 0.15s;
cursor: default;
}
.cat-item:hover { background: rgba(255,255,255,0.03); }
.cat-item--dragging {
opacity: 0.5;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.cat-item__grip {
color: var(--text-tertiary);
cursor: grab;
display: flex;
align-items: center;
flex-shrink: 0;
padding: 2px;
}
.cat-item__grip:active { cursor: grabbing; }
.cat-item__info {
flex: 1;
min-width: 0;
}
.cat-item__info strong {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.cat-item__info p {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cat-item__count {
font-size: 11px;
color: var(--text-tertiary);
background: rgba(255,255,255,0.05);
border: 1px solid var(--dark-border);
border-radius: 4px;
padding: 2px 8px;
white-space: nowrap;
flex-shrink: 0;
}
.cat-item__actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Empty state */
.cat-empty {
text-align: center;
padding: 30px 20px;
color: var(--text-tertiary);
font-size: 13px;
border: 1px dashed var(--dark-border);
border-radius: 8px;
}
/* Skeleton */
.cat-skeleton {
height: 46px;
border-radius: 8px;
background: linear-gradient(90deg, var(--dark-border) 25%, rgba(255,255,255,0.05) 50%, var(--dark-border) 75%);
background-size: 600px 100%;
animation: shimmer 1.6s infinite linear;
margin-bottom: 6px;
}
@keyframes shimmer {
0% { background-position: -600px 0; }
100% { background-position: 600px 0; }
}
/* Reuse admin button classes when rendered inside the admin shell */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.05);
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
font-family: inherit;
padding: 0;
}
.btn-icon:hover { background: rgba(99,102,241,0.15); color: var(--accent-primary); transform: none; box-shadow: none; }
.btn-icon--danger:hover { background: rgba(239,68,68,0.12); color: var(--status-down); }
.inline-confirm {
display: flex; align-items: center; gap: 5px;
font-size: 12px; color: var(--text-secondary); white-space: nowrap;
}
.btn-confirm-yes {
padding: 3px 9px; border-radius: 5px; border: none;
background: var(--status-down); color: white;
font-size: 12px; font-weight: 600; cursor: pointer; font-family: inherit;
}
.btn-confirm-yes:hover { background: #d83d3d; transform: none; box-shadow: none; }
.btn-confirm-no {
padding: 3px 9px; border-radius: 5px;
border: 1px solid var(--dark-border); background: transparent;
color: var(--text-secondary); font-size: 12px; cursor: pointer; font-family: inherit;
}
.btn-confirm-no:hover { background: rgba(255,255,255,0.05); transform: none; box-shadow: none; }

View File

@@ -0,0 +1,103 @@
.login-container {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: var(--dark-bg);
}
.login-box {
background: var(--dark-card);
padding: 40px;
border-radius: 12px;
border: 1px solid var(--dark-border);
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-box h1 {
text-align: center;
color: var(--accent-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
text-align: center;
color: var(--text-tertiary);
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 500;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid var(--dark-border);
border-radius: 8px;
font-size: 14px;
background: var(--dark-bg-secondary);
color: var(--text-primary);
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.login-btn {
width: 100%;
padding: 12px;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.login-btn:hover:not(:disabled) {
background: var(--accent-primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.login-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
color: var(--status-down);
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.demo-note {
text-align: center;
color: var(--text-tertiary);
font-size: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--dark-border);
}

View File

@@ -0,0 +1,363 @@
.setup-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--dark-bg);
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
.setup-loading,
.setup-error {
text-align: center;
color: var(--text-secondary);
font-size: 18px;
padding: 40px;
}
.setup-error {
color: var(--error-color, #ef4444);
background: rgba(239, 68, 68, 0.1);
padding: 20px;
border-radius: 12px;
border-left: 4px solid var(--error-color, #ef4444);
}
.setup-wrapper {
width: 100%;
max-width: 600px;
background: var(--dark-card);
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: 1px solid var(--dark-border);
}
.setup-header {
background: var(--dark-surface);
border-bottom: 2px solid var(--accent-primary);
padding: 40px 30px;
text-align: center;
color: white;
}
.setup-header h1 {
margin: 0 0 10px;
font-size: 28px;
font-weight: 700;
color: var(--accent-primary);
}
.setup-subtitle {
margin: 0;
font-size: 16px;
opacity: 0.8;
font-weight: 400;
color: var(--text-secondary);
}
.setup-progress {
display: flex;
justify-content: space-between;
padding: 30px;
background: var(--dark-bg);
border-bottom: 1px solid var(--dark-border);
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--text-secondary);
position: relative;
flex: 1;
}
.progress-step.active {
color: var(--accent-primary);
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--dark-surface);
border: 2px solid var(--dark-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 16px;
transition: all 0.3s ease;
color: var(--text-secondary);
}
.progress-step.active .step-number {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.4);
}
.step-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: center;
}
.setup-form {
padding: 40px 30px;
}
.setup-fields h2 {
margin: 0 0 25px;
font-size: 22px;
color: var(--text-primary);
font-weight: 600;
}
.step-info {
color: var(--text-secondary);
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 500;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--dark-border);
border-radius: 8px;
background: var(--dark-surface);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form-group small {
display: block;
margin-top: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.button-group,
.btn-group {
display: flex;
gap: 12px;
margin-top: 30px;
}
.btn-next,
.btn-back,
.btn-complete {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: inherit;
}
.btn-next,
.btn-complete {
background: var(--accent-primary);
color: white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.btn-next:hover:not(:disabled),
.btn-complete:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
}
.btn-next:active:not(:disabled),
.btn-complete:active:not(:disabled) {
transform: translateY(0);
}
.btn-back {
background: var(--dark-surface);
color: var(--text-primary);
border: 1px solid var(--dark-border);
}
.btn-back:hover {
background: var(--dark-border);
}
.btn-complete:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.setup-error-message {
margin-top: 20px;
padding: 12px 14px;
background: rgba(239, 68, 68, 0.1);
border-left: 4px solid var(--error-color, #ef4444);
color: var(--error-color, #ef4444);
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
}
/* Responsive Design */
@media (max-width: 600px) {
.setup-wrapper {
max-width: 100%;
border-radius: 0;
}
.setup-header {
padding: 30px 20px;
}
.setup-header h1 {
font-size: 24px;
}
.setup-form {
padding: 25px 20px;
}
.setup-progress {
flex-direction: column;
gap: 15px;
padding: 20px;
}
.progress-step {
flex-direction: row;
gap: 12px;
justify-content: flex-start;
}
.button-group {
flex-direction: column-reverse;
}
.btn-next,
.btn-back,
.btn-complete {
width: 100%;
}
}
/* Theme Support */
[data-theme="light"] .setup-container {
background: #f8fafc;
}
[data-theme="light"] .setup-wrapper {
background: white;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border-color: #e2e8f0;
}
[data-theme="light"] .setup-header {
background: #f1f5f9;
border-bottom-color: var(--accent-primary);
color: #1e293b;
}
[data-theme="light"] .setup-header h1 {
color: var(--accent-primary);
}
[data-theme="light"] .setup-subtitle {
color: #64748b;
}
[data-theme="light"] .setup-progress {
background: #f8fafc;
border-bottom-color: #e2e8f0;
}
[data-theme="light"] .progress-step {
color: #64748b;
}
[data-theme="light"] .progress-step.active {
color: var(--accent-primary);
}
[data-theme="light"] .step-number {
background: white;
border-color: #e2e8f0;
color: #1e293b;
}
[data-theme="light"] .progress-step.active .step-number {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
[data-theme="light"] .setup-fields h2 {
color: #1e293b;
}
[data-theme="light"] .step-info {
color: #64748b;
}
[data-theme="light"] .form-group label {
color: #1e293b;
}
[data-theme="light"] .form-group input {
background: #f8fafc;
color: #1e293b;
border-color: #e2e8f0;
}
[data-theme="light"] .form-group input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
[data-theme="light"] .form-group small {
color: #64748b;
}
[data-theme="light"] .btn-back {
background: #f1f5f9;
color: #1e293b;
border-color: #e2e8f0;
}
[data-theme="light"] .btn-back:hover {
background: #e2e8f0;
}

View File

@@ -0,0 +1,262 @@
.incident-banner-stack {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
/* Banner shell */
.incident-banner {
border-radius: 10px;
border: 1px solid transparent;
overflow: hidden;
transition: box-shadow 0.2s;
}
.incident-banner:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
.banner-critical {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.4);
}
.banner-degraded {
background: rgba(245, 158, 11, 0.08);
border-color: rgba(245, 158, 11, 0.4);
}
/* Top row */
.banner-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
gap: 12px;
}
.banner-left {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
}
/* Animated pulsing dot */
.banner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
animation: pulse-dot 2s infinite;
}
.banner-critical .banner-dot {
background: #ef4444;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
}
.banner-degraded .banner-dot {
background: #f59e0b;
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.6);
}
@keyframes pulse-dot {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); }
70% { transform: scale(1.1); box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.banner-text {
flex: 1;
min-width: 0;
}
.banner-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.banner-title {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
/* Pills */
.banner-status-pill,
.banner-source-pill {
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.banner-status-pill {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(99, 102, 241, 0.3);
}
.banner-source-pill {
background: rgba(148, 163, 184, 0.1);
color: var(--text-tertiary);
border: 1px solid var(--dark-border);
}
.banner-preview {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.banner-preview p { margin: 0; display: inline; }
/* Action buttons */
.banner-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.banner-toggle {
font-size: 11px;
color: var(--text-tertiary);
}
.banner-dismiss {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.banner-dismiss:hover {
color: var(--text-primary);
background: rgba(148, 163, 184, 0.1);
}
/* Expanded detail */
.banner-detail {
padding: 0 16px 16px 38px;
border-top: 1px solid rgba(148, 163, 184, 0.1);
margin-top: 0;
padding-top: 14px;
}
.banner-timeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.banner-update {
position: relative;
padding-left: 16px;
border-left: 2px solid var(--dark-border);
}
.banner-update-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.banner-update-time {
font-size: 12px;
color: var(--text-tertiary);
}
.banner-update-author {
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
}
.banner-update-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.banner-update-body p { margin: 0 0 6px; }
.banner-update-body p:last-child { margin-bottom: 0; }
.banner-update-body strong { color: var(--text-primary); }
.banner-update-body code {
background: var(--dark-bg-secondary);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
.banner-update-body a { color: var(--accent-primary); }
/* Status pills (timeline) */
.status-pill {
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.pill-investigating { background: rgba(239,68,68,0.12); color: #ef4444; }
.pill-identified { background: rgba(245,158,11,0.12); color: #f59e0b; }
.pill-monitoring { background: rgba(59,130,246,0.12); color: #3b82f6; }
.pill-resolved { background: rgba(16,185,129,0.12); color: #10b981; }
/* Affected services */
.banner-affected {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.banner-affected-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 600;
}
.banner-service-tag {
background: var(--dark-bg-secondary);
color: var(--text-secondary);
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
border: 1px solid var(--dark-border);
}
@media (max-width: 600px) {
.banner-preview {
display: none;
}
.banner-detail {
padding-left: 16px;
}
}

View File

@@ -0,0 +1,433 @@
/* Incident History */
.incident-history {
background:
linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)),
var(--dark-card);
padding: 22px;
border-radius: 14px;
border: 1px solid var(--dark-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16);
}
.incident-history__title {
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
margin: 0 0 18px;
letter-spacing: -0.02em;
}
/* Section wrappers */
.incidents-section {
margin-bottom: 22px;
}
.incidents-section:last-child {
margin-bottom: 0;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 0 2px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.section-header--active {
color: var(--text-primary);
}
.section-header--resolved {
color: var(--text-tertiary);
}
.section-header__count {
margin-left: auto;
min-width: 22px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
background: rgba(148, 163, 184, 0.08);
color: var(--text-tertiary);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.incidents-section--active .section-header__count {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.25);
}
/* List */
.incidents-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 0;
position: relative;
}
/* Card */
.incident-item {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid var(--dark-border);
background:
linear-gradient(180deg, rgba(255,255,255,0.018), rgba(255,255,255,0.008)),
rgba(255,255,255,0.01);
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
}
.incident-item::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--dark-border);
opacity: 0.95;
}
.incident-item--active::before {
background: linear-gradient(180deg, #f59e0b, rgba(245, 158, 11, 0.4));
}
.incident-item--resolved::before {
background: linear-gradient(180deg, #10b981, rgba(16, 185, 129, 0.35));
}
.incident-item:hover {
transform: translateY(-2px);
border-color: rgba(99, 102, 241, 0.22);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
}
.incident-item--expanded {
border-color: rgba(99, 102, 241, 0.28);
background:
linear-gradient(180deg, rgba(99,102,241,0.055), rgba(255,255,255,0.01)),
rgba(255,255,255,0.01);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.2);
}
/* Header row */
.incident-header {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: start;
padding: 16px 18px 8px 20px;
}
.incident-status-badge-wrapper {
position: static;
width: auto;
margin-bottom: 10px;
}
.incident-status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 5px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
border: 1px solid transparent;
}
.status-badge-investigating {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.22);
}
.status-badge-identified {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.22);
}
.status-badge-monitoring {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
border-color: rgba(59, 130, 246, 0.22);
}
.status-badge-resolved {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
border-color: rgba(16, 185, 129, 0.22);
}
.incident-main-content {
min-width: 0;
}
.incident-title-row {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 8px 10px;
margin-bottom: 6px;
}
.incident-title,
.incident-title-link {
margin: 0;
font-size: 17px;
line-height: 1.35;
font-weight: 700;
color: var(--text-primary);
text-decoration: none;
}
.incident-title-link:hover {
color: var(--accent-primary);
}
.incident-time {
margin: 0;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
}
.incident-pill {
padding: 3px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.pill-auto {
background: rgba(148, 163, 184, 0.08);
color: var(--text-tertiary);
border: 1px solid rgba(148, 163, 184, 0.14);
}
/* Right controls */
.incident-right-section {
display: flex;
align-items: center;
gap: 8px;
}
.duration-badge {
padding: 5px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
border: 1px solid transparent;
}
.duration-badge--active {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.22);
}
.duration-badge--resolved {
background: rgba(148, 163, 184, 0.08);
color: var(--text-tertiary);
border-color: rgba(148, 163, 184, 0.14);
}
.expand-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 10px;
border-radius: 10px;
font-size: 12px;
color: var(--text-tertiary);
background: rgba(255,255,255,0.03);
border: 1px solid var(--dark-border);
user-select: none;
transition: all 0.15s ease;
}
.incident-item:hover .expand-toggle {
color: var(--text-primary);
border-color: rgba(99, 102, 241, 0.22);
background: rgba(99, 102, 241, 0.06);
}
/* Body */
.incident-body {
padding: 0 18px 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.incident-latest-update {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Affected services */
.affected-services {
display: flex;
flex-direction: column;
gap: 8px;
}
.affected-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.service-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.service-tag {
background: rgba(99, 102, 241, 0.1);
color: var(--text-secondary);
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid rgba(99, 102, 241, 0.16);
transition: all 0.15s ease;
}
.service-tag:hover {
color: var(--text-primary);
border-color: rgba(99, 102, 241, 0.28);
background: rgba(99, 102, 241, 0.14);
}
/* Expanded content */
.incident-expanded {
padding: 0 18px 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.incident-timeline-section {
margin-top: 2px;
padding-top: 14px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.timeline-loading {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
font-style: italic;
}
/* Show more / empty */
.show-more-btn {
display: block;
width: 100%;
margin-top: 14px;
padding: 11px 14px;
border-radius: 12px;
background: rgba(255,255,255,0.02);
border: 1px solid var(--dark-border);
color: var(--text-tertiary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.show-more-btn:hover {
background: rgba(99, 102, 241, 0.06);
border-color: rgba(99, 102, 241, 0.22);
color: var(--text-primary);
}
.no-incidents {
padding: 34px 20px;
text-align: center;
color: var(--text-tertiary);
border: 1px dashed rgba(148, 163, 184, 0.14);
border-radius: 14px;
background: rgba(255,255,255,0.01);
}
.no-incidents p {
margin: 0;
font-size: 14px;
}
/* Responsive */
@media (max-width: 768px) {
.incident-history {
padding: 18px;
}
.incident-header {
grid-template-columns: 1fr;
gap: 12px;
padding: 14px 14px 8px 16px;
}
.incident-body,
.incident-expanded {
padding-left: 16px;
padding-right: 14px;
}
.incident-right-section {
justify-content: flex-start;
flex-wrap: wrap;
}
.incident-title,
.incident-title-link {
font-size: 16px;
}
}

View File

@@ -0,0 +1,156 @@
.incident-detail-card {
padding: 0;
overflow: hidden;
}
.incident-detail-header {
position: relative;
padding: 24px 24px 20px 28px;
border-bottom: 1px solid var(--dark-border);
margin-bottom: 0;
}
.incident-detail-header::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--status-degraded);
}
.incident-detail-status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.incident-detail-status .incident-status-badge {
font-size: 11px;
font-weight: 700;
padding: 5px 10px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.incident-pill {
font-size: 10px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.duration-badge {
font-size: 11px;
font-weight: 700;
padding: 5px 10px;
border-radius: 999px;
}
.incident-detail-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0 0 18px;
line-height: 1.2;
letter-spacing: -0.02em;
}
.incident-detail-meta {
display: grid;
gap: 14px;
}
.incident-detail-time {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.65;
}
.incident-detail-time strong {
color: var(--text-primary);
}
.affected-services {
font-size: 14px;
color: var(--text-primary);
}
.affected-services strong {
display: block;
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.service-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.service-tag {
display: inline-flex;
align-items: center;
padding: 5px 10px;
background: rgba(99, 102, 241, 0.1);
color: var(--text-secondary);
border: 1px solid rgba(99, 102, 241, 0.18);
border-radius: 999px;
font-size: 12px;
font-weight: 500;
}
.incident-detail-timeline {
padding: 22px 24px 24px;
margin-top: 0;
}
.incident-detail-timeline h2 {
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
margin: 0 0 16px;
}
.incident-state-card {
text-align: center;
padding: 40px;
}
.incident-state-card__text {
color: var(--text-secondary);
}
.incident-state-card__error {
color: var(--status-down);
margin-bottom: 16px;
}
.incident-state-card__actions {
display: flex;
gap: 12px;
justify-content: center;
}
@media (max-width: 768px) {
.incident-detail-header {
padding: 20px 18px 18px 22px;
}
.incident-detail-title {
font-size: 22px;
}
.incident-detail-timeline {
padding: 18px;
}
}

View File

@@ -0,0 +1,207 @@
.incident-timeline {
display: flex;
flex-direction: column;
gap: 0;
}
.timeline-empty {
font-size: 13px;
color: var(--text-tertiary);
font-style: italic;
margin: 0;
}
.timeline-entry {
display: flex;
gap: 14px;
}
.timeline-line {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 18px;
}
.timeline-node {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.35);
border: 2px solid var(--dark-card);
flex-shrink: 0;
margin-top: 6px;
}
.timeline-latest .timeline-node {
background: var(--accent-primary);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.14);
}
.timeline-connector {
width: 2px;
flex: 1;
background: linear-gradient(
180deg,
rgba(148, 163, 184, 0.18),
rgba(148, 163, 184, 0.08)
);
margin: 4px 0;
min-height: 20px;
}
.timeline-content {
flex: 1;
min-width: 0;
padding: 0 0 18px;
}
.timeline-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.timeline-time {
font-size: 12px;
color: var(--text-tertiary);
}
.timeline-author {
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
}
.timeline-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
}
.timeline-body p { margin: 0 0 6px; }
.timeline-body p:last-child { margin-bottom: 0; }
.timeline-body strong { color: var(--text-primary); }
.timeline-body em { color: var(--text-secondary); }
.timeline-body ul,
.timeline-body ol {
padding-left: 18px;
margin: 6px 0;
}
.timeline-body li { margin-bottom: 4px; }
.timeline-body code {
background: var(--dark-bg-secondary);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
}
.timeline-body pre {
background: rgba(255,255,255,0.02);
border: 1px solid var(--dark-border);
border-radius: 10px;
padding: 10px 12px;
overflow-x: auto;
margin: 8px 0;
}
.timeline-body a {
color: var(--accent-primary);
}
.timeline-body blockquote {
border-left: 3px solid rgba(99, 102, 241, 0.45);
margin: 8px 0;
padding: 4px 12px;
color: var(--text-tertiary);
font-style: italic;
background: rgba(99, 102, 241, 0.04);
border-radius: 0 8px 8px 0;
}
.timeline-status-pill {
padding: 3px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.pill-investigating {
background: rgba(239,68,68,0.12);
color: #ef4444;
border-color: rgba(239,68,68,0.2);
}
.pill-identified {
background: rgba(245,158,11,0.12);
color: #f59e0b;
border-color: rgba(245,158,11,0.2);
}
.pill-monitoring {
background: rgba(59,130,246,0.12);
color: #60a5fa;
border-color: rgba(59,130,246,0.2);
}
.pill-resolved {
background: rgba(16,185,129,0.12);
color: #10b981;
border-color: rgba(16,185,129,0.2);
}
.timeline-postmortem {
margin-bottom: 18px;
padding: 14px 16px;
background: rgba(139,92,246,0.06);
border: 1px solid rgba(139,92,246,0.2);
border-radius: 12px;
}
.timeline-postmortem-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.timeline-postmortem-icon {
font-size: 15px;
}
.timeline-postmortem-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #a78bfa;
}
.timeline-postmortem-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
}
.timeline-postmortem-body p { margin: 0 0 6px; }
.timeline-postmortem-body p:last-child { margin-bottom: 0; }
.timeline-postmortem-body strong { color: var(--text-primary); }
.timeline-postmortem-body ul,
.timeline-postmortem-body ol {
padding-left: 18px;
margin: 6px 0;
}
.timeline-postmortem-body li { margin-bottom: 4px; }
.timeline-postmortem-body code {
background: var(--dark-bg-secondary);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
}

View File

@@ -0,0 +1,113 @@
.maintenance-stack {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.maintenance-notice {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid transparent;
}
.maint-active {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.35);
}
.maint-upcoming {
background: rgba(148, 163, 184, 0.06);
border-color: rgba(148, 163, 184, 0.2);
}
.maint-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
width: 20px;
height: 20px;
}
.maint-body {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
}
.maint-title {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.maint-scope {
font-size: 13px;
color: var(--text-secondary);
}
.maint-label {
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.maint-label--active {
background: rgba(59,130,246,0.15);
color: #3b82f6;
}
.maint-countdown {
font-size: 12px;
color: var(--text-secondary);
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.25);
padding: 2px 8px;
border-radius: 20px;
}
.maint-countdown strong {
color: #f59e0b;
font-weight: 700;
}
.maint-time {
width: 100%;
font-size: 12px;
color: var(--text-tertiary);
margin: 2px 0 0;
}
.maint-desc {
width: 100%;
font-size: 13px;
color: var(--text-secondary);
margin: 4px 0 0;
}
.maint-dismiss {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.maint-dismiss:hover {
color: var(--text-primary);
background: rgba(148, 163, 184, 0.1);
}

View File

@@ -0,0 +1,482 @@
.endpoint-page {
min-height: 100vh;
background: var(--dark-bg);
padding-bottom: 28px;
}
.status-page-content {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 16px 0 28px;
}
.status-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
margin-bottom: 14px;
background: var(--dark-card);
border: 1px solid var(--dark-border);
border-radius: 14px;
}
.status-page-header__title h1 {
margin: 0;
font-size: 22px;
font-weight: 800;
color: var(--accent-primary);
letter-spacing: -0.02em;
}
.status-page-header__actions {
display: flex;
align-items: center;
gap: 10px;
}
.back-to-services-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--dark-border);
border-radius: 10px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.18s ease;
}
.back-to-services-btn:hover {
background: rgba(99, 102, 241, 0.08);
border-color: rgba(99, 102, 241, 0.24);
}
.endpoint-hero {
position: relative;
overflow: hidden;
margin-bottom: 16px;
background: var(--dark-card);
border: 1px solid var(--dark-border);
border-radius: 14px;
}
.endpoint-hero__accent {
position: absolute;
inset: 0 auto 0 0;
width: 4px;
}
.endpoint-hero__accent--up { background: var(--status-up); }
.endpoint-hero__accent--degraded { background: var(--status-degraded); }
.endpoint-hero__accent--down { background: var(--status-down); }
.endpoint-hero__accent--unknown { background: var(--dark-border); }
.endpoint-hero__content {
padding: 20px 20px 18px 24px;
}
.endpoint-hero__top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
margin-bottom: 16px;
}
.endpoint-hero__identity {
min-width: 0;
flex: 1;
}
.endpoint-hero__name {
margin: 0 0 8px;
font-size: 20px;
line-height: 1.2;
font-weight: 800;
color: var(--text-primary);
}
.endpoint-hero__url-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.endpoint-hero__url {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 13px;
color: var(--text-tertiary);
text-decoration: none;
}
.endpoint-hero__url:hover {
color: var(--accent-primary);
}
.endpoint-hero__url--plain {
text-decoration: none;
}
.copy-btn {
background: transparent;
border: 1px solid var(--dark-border);
color: var(--text-tertiary);
padding: 4px 6px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.18s ease;
}
.copy-btn:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
}
.endpoint-hero__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.status-callout {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 10px;
font-weight: 600;
}
.status-callout--up {
background: rgba(16, 185, 129, 0.12);
color: var(--status-up);
}
.status-callout--down {
background: rgba(239, 68, 68, 0.12);
color: var(--status-down);
}
.status-callout--degraded {
background: rgba(245, 158, 11, 0.12);
color: var(--status-degraded);
}
.status-callout--unknown {
background: rgba(148, 163, 184, 0.1);
color: var(--text-tertiary);
}
.endpoint-hero__badges {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.badge-incident,
.badge-maintenance {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
padding: 5px 10px;
border-radius: 999px;
letter-spacing: 0.03em;
}
.badge-incident {
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.22);
}
.badge-maintenance {
color: #818cf8;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.22);
}
.endpoint-meta-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--dark-border);
border-radius: 16px;
padding: 4px 10px;
}
.endpoint-glance-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding-top: 16px;
border-top: 1px solid var(--dark-border);
}
.glance-card {
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(148, 163, 184, 0.08);
}
.glance-label {
display: block;
margin-bottom: 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
font-weight: 700;
}
.glance-value {
display: block;
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.1;
}
.endpoint-dashboard--balanced {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
align-items: start;
margin-bottom: 16px;
}
.endpoint-dashboard__sidebar,
.endpoint-dashboard__main {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.section-card {
background: var(--dark-card);
border: 1px solid var(--dark-border);
border-radius: 14px;
padding: 16px 18px;
min-width: 0;
overflow: hidden;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
margin: 0 0 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
font-weight: 800;
}
/* Left rail cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.kpi-box {
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(148, 163, 184, 0.08);
}
.kpi-box__value {
display: block;
font-size: 22px;
line-height: 1;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 6px;
}
.kpi-box__value--danger {
color: #f87171;
}
.kpi-box__label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-tertiary);
font-weight: 700;
}
/* Sidebar uptime widget */
.endpoint-sidebar-chart {
min-width: 0;
overflow: hidden;
}
/* Main cards */
.chart-section {
min-height: 220px;
}
.chart-section--large {
min-height: 340px;
}
/* Make 90-day history feel bigger and more intentional */
.endpoint-dashboard__main .chart-section:not(.chart-section--large) {
min-height: 250px;
display: flex;
flex-direction: column;
justify-content: center;
}
/* Recent events card now fills the awkward leftover space better */
.endpoint-dashboard__main .section-card:last-child {
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
}
.endpoint-empty-state {
color: var(--text-tertiary);
font-size: 13px;
}
/* Events */
.events-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.event-item {
display: flex;
align-items: center;
gap: 10px;
}
.event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.event-dot--up { background: var(--status-up); }
.event-dot--down { background: var(--status-down); }
.event-dot--degraded { background: var(--status-degraded); }
.event-dot--unknown { background: var(--dark-border); }
.event-content {
display: flex;
flex-direction: column;
gap: 1px;
}
.event-status {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.event-time {
font-size: 11px;
color: var(--text-tertiary);
}
.endpoint-incidents-full {
margin-top: 4px;
}
.endpoint-incidents-full .incident-history {
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.endpoint-incidents-full .incident-history__title {
display: none;
}
@media (max-width: 980px) {
.endpoint-dashboard--balanced {
grid-template-columns: 1fr;
}
.endpoint-glance-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.endpoint-dashboard__main .section-card:last-child {
min-height: 96px;
}
}
@media (max-width: 768px) {
.status-page-content {
width: min(100% - 24px, 1180px);
padding-top: 12px;
}
.status-page-header {
flex-direction: column;
align-items: stretch;
}
.status-page-header__actions {
justify-content: space-between;
}
.endpoint-hero__top {
flex-direction: column;
align-items: stretch;
}
.endpoint-hero__side {
align-items: flex-start;
}
}
@media (max-width: 520px) {
.endpoint-glance-grid,
.kpi-grid {
grid-template-columns: 1fr;
}
.endpoint-hero__content {
padding: 18px 16px 16px 20px;
}
}

View File

@@ -0,0 +1,162 @@
.response-time-chart {
background: var(--dark-card);
padding: 0;
border-radius: 0;
border: none;
margin: 0;
}
.response-time-chart__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.response-time-chart__heading {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
flex: 1;
}
.response-time-chart__title-group {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.response-time-chart h3 {
color: var(--text-primary);
font-size: 16px;
margin: 0;
font-weight: 700;
}
.response-time-chart__range {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
font-weight: 500;
}
.rt-stats-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.rt-stat-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--dark-border, #3d3d54);
color: var(--text-primary);
font-size: 12px;
}
.rt-stat-chip__label {
color: var(--text-tertiary, #94a3b8);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.response-time-chart__presets {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.rt-preset-btn {
padding: 4px 10px;
border-radius: 20px;
border: 1px solid var(--dark-border, #3d3d54);
background: transparent;
color: var(--text-secondary, #94a3b8);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
line-height: 1.6;
}
.rt-preset-btn:hover {
background: rgba(99, 102, 241, 0.12);
border-color: #6366f1;
color: #6366f1;
}
.rt-preset-btn--active {
background: rgba(99, 102, 241, 0.18);
border-color: #6366f1;
color: #6366f1;
font-weight: 700;
}
.chart-empty {
background: transparent;
border: none;
border-radius: 12px;
padding: 32px 0 12px;
text-align: center;
color: var(--text-tertiary);
}
.chart-empty p {
margin: 0;
}
.rt-tooltip {
background: var(--dark-card, #1a1a2e);
border: 1px solid var(--dark-border, #3d3d54);
border-radius: 8px;
padding: 10px 14px;
color: var(--text-primary, #f8fafc);
font-size: 13px;
line-height: 1.6;
}
.rt-tooltip__label {
margin: 0 0 4px;
font-weight: 700;
}
.rt-tooltip__row {
margin: 0;
}
.rt-tooltip__row--avg {
color: #6366f1;
}
.rt-tooltip__row--min {
color: #10b981;
}
.rt-tooltip__row--max {
color: #f59e0b;
}
.rt-tooltip__checks {
margin: 4px 0 0;
color: var(--text-tertiary, #94a3b8);
}
@media (max-width: 768px) {
.response-time-chart__header {
flex-direction: column;
align-items: stretch;
}
.response-time-chart__presets {
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,125 @@
.shard-events {
border-top: 1px solid var(--dark-border);
padding-top: 16px;
margin-top: 16px;
}
.shard-events__title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-tertiary);
margin: 0 0 12px;
}
.shard-events__list {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
padding-left: 18px;
/* Show ~6 events, scroll for more */
max-height: 340px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--dark-border) transparent;
padding-right: 4px;
}
/* Rail */
.shard-events__list::before {
content: '';
position: absolute;
left: 4px;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--dark-border);
border-radius: 2px;
}
.shard-event {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.shard-event__dot {
position: absolute;
left: -18px;
top: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
border: 2px solid var(--dark-card, #1a1a2e);
z-index: 1;
flex-shrink: 0;
}
.shard-event--ongoing .shard-event__dot {
background: #ef4444;
animation: shard-dot-pulse 1.8s ease-in-out infinite;
}
@keyframes shard-dot-pulse {
0%, 100% { box-shadow: 0 0 0 2px rgba(239,68,68,0.2); }
50% { box-shadow: 0 0 0 5px rgba(239,68,68,0.06); }
}
.shard-event__body {
flex: 1;
background: rgba(255,255,255,0.03);
border: 1px solid var(--dark-border);
border-radius: 8px;
padding: 8px 12px;
}
.shard-event__top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.shard-event__name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.shard-event__badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 7px;
border-radius: 20px;
}
.shard-event__badge--resolved {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
}
.shard-event__badge--ongoing {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.shard-event__duration {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-left: auto;
}
.shard-event__times {
display: flex;
gap: 14px;
font-size: 11px;
color: var(--text-tertiary);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
/* Categories container */
.categories-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
/* Category section wrapper */
.cat-section {
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--dark-border);
background: var(--dark-card);
}
/* Category header (accordion toggle) */
.cat-header {
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
transition: background 0.15s;
font-family: inherit;
color: inherit;
}
.cat-header:hover {
background: rgba(255, 255, 255, 0.04);
}
/* When open, show a subtle separator */
.cat-section:not(:has(.cat-body:empty)) .cat-header {
border-bottom-color: var(--dark-border);
}
.cat-header__left {
display: flex;
align-items: center;
gap: 8px;
}
.cat-header__right {
display: flex;
align-items: center;
gap: 8px;
}
.cat-chevron {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cat-header__name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.cat-header__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 5px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
}
.cat-all-up {
font-size: 11px;
font-weight: 600;
color: var(--status-up);
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 20px;
padding: 2px 10px;
letter-spacing: 0.02em;
}
/* Status icons in category header */
.cat-status-icon { flex-shrink: 0; }
.cat-status-icon--up { color: var(--status-up); }
.cat-status-icon--degraded { color: var(--status-degraded); }
.cat-status-icon--down { color: var(--status-down); }
.cat-status-icon--unknown { color: var(--text-tertiary); }
/* Category body (endpoint list) */
.cat-body {
padding: 0;
background: var(--dark-bg);
}
/* Service row */
.svc-row {
display: flex;
align-items: stretch;
cursor: pointer;
border-bottom: 1px solid var(--dark-border);
transition: background 0.15s;
outline: none;
min-height: 56px;
}
.svc-row:last-child {
border-bottom: none;
}
.svc-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.svc-row:focus-visible {
box-shadow: inset 0 0 0 2px var(--accent-primary);
}
/* Left status accent bar */
.svc-row__accent {
width: 4px;
flex-shrink: 0;
background: var(--dark-border);
transition: background 0.2s;
}
.svc-row__accent--up { background: var(--status-up); }
.svc-row__accent--degraded { background: var(--status-degraded); }
.svc-row__accent--down { background: var(--status-down); }
/* Main content area (fills the row after the accent bar) */
.svc-row__main {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
min-width: 0;
}
/* Left side: name + URL */
.svc-row__left {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.svc-row__name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.svc-row__url {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Right side: rt + uptime + status badge + link icon */
.svc-row__right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.svc-row__rt {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--dark-border);
border-radius: 4px;
padding: 2px 8px;
white-space: nowrap;
}
.svc-row__uptime {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.svc-row__uptime-period {
font-size: 10px;
font-weight: 500;
color: var(--text-tertiary);
}
/* Status badge */
.svc-row__badge {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
}
.svc-row__badge--up {
background: rgba(16, 185, 129, 0.12);
color: var(--status-up);
border: 1px solid rgba(16, 185, 129, 0.25);
}
.svc-row__badge--degraded {
background: rgba(245, 158, 11, 0.12);
color: var(--status-degraded);
border: 1px solid rgba(245, 158, 11, 0.25);
}
.svc-row__badge--down {
background: rgba(239, 68, 68, 0.12);
color: var(--status-down);
border: 1px solid rgba(239, 68, 68, 0.25);
}
.svc-row__badge--unknown {
background: rgba(148, 163, 184, 0.1);
color: var(--text-tertiary);
border: 1px solid rgba(148, 163, 184, 0.15);
}
.svc-row__link-icon {
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.15s;
flex-shrink: 0;
}
.svc-row:hover .svc-row__link-icon {
opacity: 1;
}
/* Responsive: hide rt and uptime on narrow screens */
@media (max-width: 540px) {
.svc-row__rt,
.svc-row__uptime {
display: none;
}
}

View File

@@ -0,0 +1,149 @@
.uptime-heatmap {
position: relative;
margin-top: 0;
}
/* Standalone usage on endpoint page */
.uptime-heatmap:not(.uptime-heatmap--compact) {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
overflow-x: auto;
}
/* Header */
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.heatmap-title {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-tertiary, #64748b);
}
.heatmap-legend-cell {
width: 12px;
height: 12px;
border-radius: 3px;
}
/* Layout */
.heatmap-months {
position: relative;
height: 18px;
margin-bottom: 6px;
}
.heatmap-month-label {
position: absolute;
font-size: 10px;
color: var(--text-tertiary, #64748b);
white-space: nowrap;
}
.heatmap-grid {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: auto;
min-width: max-content;
}
.heatmap-dow {
display: flex;
flex-direction: column;
flex-shrink: 0;
margin-right: 8px;
}
.heatmap-dow-label {
font-size: 9px;
color: var(--text-tertiary, #64748b);
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 3px;
flex-shrink: 0;
}
.heatmap-col {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 3px;
margin-right: 3px;
}
/* Bigger cells so the chart uses more of the card width */
.heatmap-cell {
width: 13px !important;
height: 13px !important;
border-radius: 3px;
cursor: default;
flex-shrink: 0;
transition: opacity 0.1s, transform 0.1s;
}
.heatmap-cell:not(.heatmap-cell--empty):hover {
opacity: 0.85;
transform: scale(1.18);
}
.heatmap-cell--empty {
background: transparent !important;
}
/* Compact mode remains small */
.uptime-heatmap--compact .heatmap-grid {
gap: 2px;
overflow-x: hidden;
}
.uptime-heatmap--compact .heatmap-col {
gap: 2px;
margin-right: 2px;
}
.uptime-heatmap--compact .heatmap-cell {
width: auto !important;
height: auto !important;
border-radius: 1px;
}
/* Tooltip */
.heatmap-tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: var(--dark-card, #1a1a2e);
border: 1px solid var(--dark-border, #3d3d54);
color: var(--text-primary, #f8fafc);
font-size: 12px;
padding: 5px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.heatmap-loading {
font-size: 12px;
color: var(--text-tertiary, #64748b);
padding: 8px 0;
}

View File

@@ -0,0 +1,98 @@
.uptime-stats {
display: flex;
flex-direction: column;
gap: 18px;
align-items: stretch;
background: var(--dark-card);
padding: 0;
border-radius: 0;
border: none;
}
.uptime-chart {
width: 100%;
height: 190px;
flex: 0 0 auto;
}
.uptime-details {
min-width: 0;
}
.uptime-percentage {
text-align: center;
margin-bottom: 14px;
}
.uptime-value {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 6px;
}
.uptime-label {
font-size: 12px;
color: var(--text-tertiary);
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.06em;
}
.uptime-info {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.025);
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.16);
position: relative;
overflow: hidden;
}
.info-item::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 2px;
background: rgba(99, 102, 241, 0.7);
}
.info-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
}
.info-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.uptime-loading,
.uptime-error {
padding: 20px;
text-align: center;
color: var(--text-tertiary);
background: transparent;
border-radius: 12px;
border: 1px solid var(--dark-border);
}
.uptime-error {
color: var(--status-down);
}
@media (max-width: 768px) {
.uptime-chart {
height: 200px;
}
}

View File

@@ -0,0 +1,88 @@
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.loading-spinner--small .spinner-ring {
width: 32px;
height: 32px;
}
.loading-spinner--medium .spinner-ring {
width: 48px;
height: 48px;
}
.loading-spinner--large .spinner-ring {
width: 64px;
height: 64px;
}
.spinner-ring {
display: inline-block;
position: relative;
width: 48px;
height: 48px;
}
.spinner-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 100%;
height: 100%;
border: 3px solid var(--accent-primary);
border-radius: 50%;
animation: spinner-ring-animation 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: var(--accent-primary) transparent transparent transparent;
}
.spinner-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.spinner-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.spinner-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes spinner-ring-animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner__text {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
margin: 0;
animation: loading-pulse 1.5s ease-in-out infinite;
}
@keyframes loading-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Full page loading wrapper */
.loading-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--dark-bg);
}

View File

@@ -0,0 +1,139 @@
/* Dark Mode Theme */
:root {
color-scheme: dark;
/* Dark mode colors (matching CYPHYRIX control panel) */
--dark-bg: #0f0f1e;
--dark-bg-secondary: #1a1a2e;
--dark-card: #2d2d44;
--dark-border: #3d3d54;
/* Accent colors */
--accent-primary: #6366f1;
--accent-primary-dark: #4f46e5;
--accent-secondary: #8b5cf6;
/* Status colors */
--status-up: #10b981;
--status-degraded: #f59e0b;
--status-down: #ef4444;
/* Text colors */
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
/* Chart colors */
--chart-green: #10b981;
--chart-red: #ef4444;
}
/* Light Mode */
[data-theme="light"] {
color-scheme: light;
--dark-bg: #ffffff;
--dark-bg-secondary: #f8fafc;
--dark-card: #f1f5f9;
--dark-border: #e2e8f0;
/* Keep accent colors similar but slightly adjustedfor light mode */
--accent-primary: #4f46e5;
--accent-primary-dark: #4338ca;
--accent-secondary: #7c3aed;
/* Text colors for light mode */
--text-primary: #1e293b;
--text-secondary: #475569;
--text-tertiary: #64748b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--dark-bg);
color: var(--text-primary);
min-height: 100vh;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
a {
text-decoration: none;
color: var(--accent-primary);
transition: color 0.2s;
}
a:hover {
color: var(--accent-primary-dark);
}
button {
cursor: pointer;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
background: var(--accent-primary);
color: white;
}
button:hover {
background: var(--accent-primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
input, textarea, select {
font-family: inherit;
padding: 10px 12px;
border: 1px solid var(--dark-border);
border-radius: 8px;
font-size: 14px;
background: var(--dark-bg-secondary);
color: var(--text-primary);
transition: all 0.3s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--dark-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--dark-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}