Version 0.4
This commit is contained in:
41
frontend/package.json
Normal file
41
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
91
frontend/public/index.html
Normal file
91
frontend/public/index.html
Normal 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
148
frontend/src/App.jsx
Normal 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;
|
||||
6
frontend/src/app/AppProviders.jsx
Normal file
6
frontend/src/app/AppProviders.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '../context/ThemeContext';
|
||||
|
||||
export default function AppProviders({ children }) {
|
||||
return <ThemeProvider>{children}</ThemeProvider>;
|
||||
}
|
||||
42
frontend/src/app/routes.jsx
Normal file
42
frontend/src/app/routes.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/context/ThemeContext.jsx
Normal file
34
frontend/src/context/ThemeContext.jsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
779
frontend/src/features/admin/components/AdminIncidents.jsx
Normal file
779
frontend/src/features/admin/components/AdminIncidents.jsx
Normal 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;
|
||||
289
frontend/src/features/admin/components/ApiKeyManager.jsx
Normal file
289
frontend/src/features/admin/components/ApiKeyManager.jsx
Normal 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>
|
||||
<code>GET /api/v1/status.json</code> with <code>Authorization: Bearer <key></code> or <code>X-API-Key: <key></code>.
|
||||
Public fields are available without a key.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyManager;
|
||||
203
frontend/src/features/admin/components/CategoryManager.jsx
Normal file
203
frontend/src/features/admin/components/CategoryManager.jsx
Normal 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;
|
||||
4
frontend/src/features/admin/components/index.js
Normal file
4
frontend/src/features/admin/components/index.js
Normal 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';
|
||||
1435
frontend/src/features/admin/pages/AdminDashboard.jsx
Normal file
1435
frontend/src/features/admin/pages/AdminDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/src/features/admin/pages/index.js
Normal file
1
frontend/src/features/admin/pages/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AdminDashboardPage } from './AdminDashboard';
|
||||
71
frontend/src/features/auth/pages/Login.jsx
Normal file
71
frontend/src/features/auth/pages/Login.jsx
Normal 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;
|
||||
307
frontend/src/features/auth/pages/Setup.jsx
Normal file
307
frontend/src/features/auth/pages/Setup.jsx
Normal 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;
|
||||
2
frontend/src/features/auth/pages/index.js
Normal file
2
frontend/src/features/auth/pages/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoginPage } from './Login';
|
||||
export { default as SetupPage } from './Setup';
|
||||
133
frontend/src/features/incidents/components/IncidentBanner.jsx
Normal file
133
frontend/src/features/incidents/components/IncidentBanner.jsx
Normal 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;
|
||||
286
frontend/src/features/incidents/components/IncidentHistory.jsx
Normal file
286
frontend/src/features/incidents/components/IncidentHistory.jsx
Normal 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;
|
||||
@@ -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;
|
||||
105
frontend/src/features/incidents/components/MaintenanceNotice.jsx
Normal file
105
frontend/src/features/incidents/components/MaintenanceNotice.jsx
Normal 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;
|
||||
4
frontend/src/features/incidents/components/index.js
Normal file
4
frontend/src/features/incidents/components/index.js
Normal 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';
|
||||
180
frontend/src/features/incidents/pages/IncidentPage.jsx
Normal file
180
frontend/src/features/incidents/pages/IncidentPage.jsx
Normal 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;
|
||||
1
frontend/src/features/incidents/pages/index.js
Normal file
1
frontend/src/features/incidents/pages/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as IncidentPage } from './IncidentPage';
|
||||
333
frontend/src/features/status/components/EndpointDetailView.jsx
Normal file
333
frontend/src/features/status/components/EndpointDetailView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
frontend/src/features/status/components/ResponseTimeChart.jsx
Normal file
311
frontend/src/features/status/components/ResponseTimeChart.jsx
Normal 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;
|
||||
61
frontend/src/features/status/components/ShardEvents.jsx
Normal file
61
frontend/src/features/status/components/ShardEvents.jsx
Normal 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;
|
||||
53
frontend/src/features/status/components/ShardUptime.jsx
Normal file
53
frontend/src/features/status/components/ShardUptime.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/features/status/components/StatusPageCategories.jsx
Normal file
134
frontend/src/features/status/components/StatusPageCategories.jsx
Normal 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;
|
||||
158
frontend/src/features/status/components/UptimeHeatmap.jsx
Normal file
158
frontend/src/features/status/components/UptimeHeatmap.jsx
Normal 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;
|
||||
95
frontend/src/features/status/components/UptimeStats.jsx
Normal file
95
frontend/src/features/status/components/UptimeStats.jsx
Normal 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;
|
||||
8
frontend/src/features/status/components/index.js
Normal file
8
frontend/src/features/status/components/index.js
Normal 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';
|
||||
143
frontend/src/features/status/hooks/useStatusData.js
Normal file
143
frontend/src/features/status/hooks/useStatusData.js
Normal 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,
|
||||
};
|
||||
}
|
||||
6
frontend/src/features/status/pages/EndpointPage.jsx
Normal file
6
frontend/src/features/status/pages/EndpointPage.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import StatusPage from './StatusPage';
|
||||
|
||||
export default function EndpointPage({ socket }) {
|
||||
return <StatusPage socket={socket} />;
|
||||
}
|
||||
338
frontend/src/features/status/pages/StatusPage.jsx
Normal file
338
frontend/src/features/status/pages/StatusPage.jsx
Normal 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;
|
||||
2
frontend/src/features/status/pages/index.js
Normal file
2
frontend/src/features/status/pages/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as StatusPage } from './StatusPage';
|
||||
export { default as EndpointPage } from './EndpointPage';
|
||||
25
frontend/src/index.js
Normal file
25
frontend/src/index.js
Normal 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);
|
||||
125
frontend/src/shared/api/adminApi.js
Normal file
125
frontend/src/shared/api/adminApi.js
Normal 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}`);
|
||||
}
|
||||
53
frontend/src/shared/api/authApi.js
Normal file
53
frontend/src/shared/api/authApi.js
Normal 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);
|
||||
}
|
||||
22
frontend/src/shared/api/client.js
Normal file
22
frontend/src/shared/api/client.js
Normal 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;
|
||||
49
frontend/src/shared/api/publicApi.js
Normal file
49
frontend/src/shared/api/publicApi.js
Normal 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,
|
||||
});
|
||||
}
|
||||
18
frontend/src/shared/components/LoadingSpinner.jsx
Normal file
18
frontend/src/shared/components/LoadingSpinner.jsx
Normal 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;
|
||||
1
frontend/src/shared/components/index.js
Normal file
1
frontend/src/shared/components/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
13
frontend/src/shared/constants/status.js
Normal file
13
frontend/src/shared/constants/status.js
Normal 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',
|
||||
};
|
||||
17
frontend/src/shared/hooks/usePolling.js
Normal file
17
frontend/src/shared/hooks/usePolling.js
Normal 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;
|
||||
}
|
||||
19
frontend/src/shared/utils/format.js
Normal file
19
frontend/src/shared/utils/format.js
Normal 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)}%`;
|
||||
}
|
||||
41
frontend/src/shared/utils/status.js
Normal file
41
frontend/src/shared/utils/status.js
Normal 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))}%`;
|
||||
}
|
||||
18
frontend/src/shared/utils/storage.js
Normal file
18
frontend/src/shared/utils/storage.js
Normal 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;
|
||||
}
|
||||
183
frontend/src/styles/base/App.css
Normal file
183
frontend/src/styles/base/App.css
Normal 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);
|
||||
}
|
||||
54
frontend/src/styles/base/index.css
Normal file
54
frontend/src/styles/base/index.css
Normal 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);
|
||||
}
|
||||
1039
frontend/src/styles/features/admin/AdminDashboard.css
Normal file
1039
frontend/src/styles/features/admin/AdminDashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
557
frontend/src/styles/features/admin/AdminIncidents.css
Normal file
557
frontend/src/styles/features/admin/AdminIncidents.css
Normal 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%; }
|
||||
}
|
||||
309
frontend/src/styles/features/admin/ApiKeyManager.css
Normal file
309
frontend/src/styles/features/admin/ApiKeyManager.css
Normal 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);
|
||||
}
|
||||
166
frontend/src/styles/features/admin/CategoryManager.css
Normal file
166
frontend/src/styles/features/admin/CategoryManager.css
Normal 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; }
|
||||
103
frontend/src/styles/features/auth/Login.css
Normal file
103
frontend/src/styles/features/auth/Login.css
Normal 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);
|
||||
}
|
||||
363
frontend/src/styles/features/auth/Setup.css
Normal file
363
frontend/src/styles/features/auth/Setup.css
Normal 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;
|
||||
}
|
||||
262
frontend/src/styles/features/incidents/IncidentBanner.css
Normal file
262
frontend/src/styles/features/incidents/IncidentBanner.css
Normal 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;
|
||||
}
|
||||
}
|
||||
433
frontend/src/styles/features/incidents/IncidentHistory.css
Normal file
433
frontend/src/styles/features/incidents/IncidentHistory.css
Normal 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;
|
||||
}
|
||||
}
|
||||
156
frontend/src/styles/features/incidents/IncidentPage.css
Normal file
156
frontend/src/styles/features/incidents/IncidentPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
207
frontend/src/styles/features/incidents/IncidentTimeline.css
Normal file
207
frontend/src/styles/features/incidents/IncidentTimeline.css
Normal 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;
|
||||
}
|
||||
113
frontend/src/styles/features/incidents/MaintenanceNotice.css
Normal file
113
frontend/src/styles/features/incidents/MaintenanceNotice.css
Normal 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);
|
||||
}
|
||||
482
frontend/src/styles/features/status/EndpointDetailView.css
Normal file
482
frontend/src/styles/features/status/EndpointDetailView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
162
frontend/src/styles/features/status/ResponseTimeChart.css
Normal file
162
frontend/src/styles/features/status/ResponseTimeChart.css
Normal 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;
|
||||
}
|
||||
}
|
||||
125
frontend/src/styles/features/status/ShardEvents.css
Normal file
125
frontend/src/styles/features/status/ShardEvents.css
Normal 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);
|
||||
}
|
||||
1725
frontend/src/styles/features/status/StatusPage.css
Normal file
1725
frontend/src/styles/features/status/StatusPage.css
Normal file
File diff suppressed because it is too large
Load Diff
258
frontend/src/styles/features/status/StatusPageCategories.css
Normal file
258
frontend/src/styles/features/status/StatusPageCategories.css
Normal 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;
|
||||
}
|
||||
}
|
||||
149
frontend/src/styles/features/status/UptimeHeatmap.css
Normal file
149
frontend/src/styles/features/status/UptimeHeatmap.css
Normal 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;
|
||||
}
|
||||
98
frontend/src/styles/features/status/UptimeStats.css
Normal file
98
frontend/src/styles/features/status/UptimeStats.css
Normal 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;
|
||||
}
|
||||
}
|
||||
88
frontend/src/styles/shared/LoadingSpinner.css
Normal file
88
frontend/src/styles/shared/LoadingSpinner.css
Normal 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);
|
||||
}
|
||||
139
frontend/src/styles/tokens/theme.css
Normal file
139
frontend/src/styles/tokens/theme.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user