Version 0.4

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

139
.gitignore vendored Normal file
View File

@@ -0,0 +1,139 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Build output
frontend/build/
backend/build/
# Other
package-lock.json
status.db

17
LICENSE
View File

@@ -1,18 +1,9 @@
MIT License MIT License
Copyright (c) 2026 ArcaneNeko Copyright (c) 2026 Rinanyae
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

26
backend/.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Server Configuration
PORT=5000
NODE_ENV=development
# JWT Secret (change this to something random!)
JWT_SECRET=your_super_secret_jwt_key_change_this
# Encryption key for secrets at rest (SMTP passwords, etc.)
# If not set, falls back to JWT_SECRET. Using a separate key is recommended
# so that a JWT_SECRET compromise does not also expose encrypted data.
ENCRYPTION_KEY=your_separate_encryption_key_change_this
# Database
DATABASE_PATH=../data/status.db
# CORS (whitelist frontend URL)
FRONTEND_URL=http://localhost:3000
# Monitoring defaults (in seconds)
DEFAULT_CHECK_INTERVAL=300
DEFAULT_TIMEOUT=10
# Trust reverse proxy headers (X-Forwarded-For) for correct client IPs.
# Set to 'false' if the app is NOT behind a reverse proxy (nginx, Cloudflare, etc.).
# Default: true
TRUST_PROXY=true

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "arcane-status-backend",
"version": "0.4.0",
"description": "Status page backend",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "node --test tests/smoke.api.test.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"node-cron": "^4.2.1",
"nodemailer": "^6.10.1",
"socket.io": "^4.8.3",
"sqlite": "^5.1.1",
"sqlite3": "^6.0.1"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

View File

@@ -0,0 +1,131 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { getDatabase } = require('../models/database');
/**
* Generate a cryptographically secure API key.
* Format: sk_<32 random hex chars> (total ~35 chars)
* Prefix stored: first 12 chars of the full key, enough to narrow DB lookup
* without leaking the secret.
*/
function generateApiKey() {
const secret = crypto.randomBytes(24).toString('hex'); // 48 hex chars
const rawKey = `sk_${secret}`;
const prefix = rawKey.substring(0, 12); // "sk_" + 9 chars
return { rawKey, prefix };
}
// GET /admin/api-keys
async function listApiKeys(req, res) {
try {
const db = getDatabase();
const keys = await db.all(`
SELECT ak.id, ak.name, ak.key_prefix, ak.scope, ak.endpoint_ids,
ak.active, ak.last_used_at, ak.expires_at, ak.created_at,
u.name AS created_by_name
FROM api_keys ak
LEFT JOIN users u ON u.id = ak.created_by
ORDER BY ak.created_at DESC
`);
// Parse endpoint_ids JSON
for (const key of keys) {
key.endpoint_ids = key.endpoint_ids ? JSON.parse(key.endpoint_ids) : null;
}
res.json(keys);
} catch (err) {
console.error('List API keys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
// POST /admin/api-keys
async function createApiKey(req, res) {
try {
const { name, scope = 'global', endpoint_ids = null, expires_at = null } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Key name is required' });
}
if (!['global', 'endpoint'].includes(scope)) {
return res.status(400).json({ error: 'scope must be "global" or "endpoint"' });
}
if (scope === 'endpoint') {
if (!Array.isArray(endpoint_ids) || endpoint_ids.length === 0) {
return res.status(400).json({ error: 'endpoint_ids array required for endpoint-scoped keys' });
}
}
const { rawKey, prefix } = generateApiKey();
const hash = await bcrypt.hash(rawKey, 12);
const db = getDatabase();
const result = await db.run(
`INSERT INTO api_keys (name, key_hash, key_prefix, scope, endpoint_ids, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
name.trim(),
hash,
prefix,
scope,
scope === 'endpoint' ? JSON.stringify(endpoint_ids) : null,
req.user.id,
expires_at || null
]
);
const created = await db.get('SELECT * FROM api_keys WHERE id = ?', [result.lastID]);
created.endpoint_ids = created.endpoint_ids ? JSON.parse(created.endpoint_ids) : null;
// Return raw key in this response only, it will never be recoverable again
res.status(201).json({
...created,
raw_key: rawKey,
_warning: 'Store this key securely. It will not be shown again.'
});
} catch (err) {
console.error('Create API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
// DELETE /admin/api-keys/:id (revoke)
async function revokeApiKey(req, res) {
try {
const db = getDatabase();
const key = await db.get('SELECT * FROM api_keys WHERE id = ?', [req.params.id]);
if (!key) {
return res.status(404).json({ error: 'API key not found' });
}
await db.run('UPDATE api_keys SET active = 0 WHERE id = ?', [req.params.id]);
res.json({ message: 'API key revoked' });
} catch (err) {
console.error('Revoke API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
// DELETE (hard delete) /admin/api-keys/:id/delete
async function deleteApiKey(req, res) {
try {
const db = getDatabase();
const key = await db.get('SELECT * FROM api_keys WHERE id = ?', [req.params.id]);
if (!key) {
return res.status(404).json({ error: 'API key not found' });
}
await db.run('DELETE FROM api_keys WHERE id = ?', [req.params.id]);
res.json({ message: 'API key deleted' });
} catch (err) {
console.error('Delete API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = { listApiKeys, createApiKey, revokeApiKey, deleteApiKey };

View File

@@ -0,0 +1,42 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { getDatabase } = require('../models/database');
async function login(req, res) {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
const db = getDatabase();
const user = await db.get('SELECT * FROM users WHERE email = ? AND active = 1', [email]);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
if (!isPasswordValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate JWT token
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
login
};

View File

@@ -0,0 +1,152 @@
const {
listCategories,
getCategoryById: getCategoryRecordById,
getCategoryEndpointCount,
listEndpointsForCategory,
createCategoryRecord,
getMaxCategorySortOrder,
updateCategoryRecord,
clearCategoryFromEndpoints,
deleteCategoryRecord,
reorderCategoryRecords,
} = require('../data/categoryData');
const { getLatestCheckResult } = require('../data/endpointData');
async function getAllCategories(req, res) {
try {
const categories = await listCategories();
for (let category of categories) {
const countResult = await getCategoryEndpointCount(category.id);
category.endpoint_count = countResult?.count || 0;
}
res.json(categories);
} catch (error) {
console.error('Get categories error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getCategoryById(req, res) {
try {
const category = await getCategoryRecordById(req.params.id);
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
const endpoints = await listEndpointsForCategory(category.id);
for (let endpoint of endpoints) {
const result = await getLatestCheckResult(endpoint.id);
endpoint.latest = result || null;
}
res.json({ ...category, endpoints });
} catch (error) {
console.error('Get category error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function createCategory(req, res) {
try {
const { name, description } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const maxOrder = await getMaxCategorySortOrder();
const newOrder = (maxOrder?.max || 0) + 1;
const result = await createCategoryRecord(name, description || null, newOrder);
const category = await getCategoryRecordById(result.lastID);
res.status(201).json(category);
} catch (error) {
console.error('Create category error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateCategory(req, res) {
try {
const { name, description } = req.body;
const existing = await getCategoryRecordById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Category not found' });
}
await updateCategoryRecord(req.params.id, name, description || null);
const category = await getCategoryRecordById(req.params.id);
res.json(category);
} catch (error) {
console.error('Update category error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function deleteCategory(req, res) {
try {
const existing = await getCategoryRecordById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Category not found' });
}
await clearCategoryFromEndpoints(req.params.id);
await deleteCategoryRecord(req.params.id);
res.json({ success: true });
} catch (error) {
console.error('Delete category error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function reorderCategories(req, res) {
try {
const { order } = req.body;
if (!Array.isArray(order)) {
return res.status(400).json({ error: 'Order must be an array of category IDs' });
}
await reorderCategoryRecords(order);
const categories = await listCategories();
for (let category of categories) {
const countResult = await getCategoryEndpointCount(category.id);
category.endpoint_count = countResult?.count || 0;
}
res.json(categories);
} catch (error) {
console.error('Reorder categories error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getAllCategories,
getCategoryById,
createCategory,
updateCategory,
deleteCategory,
reorderCategories
};
async function getPublicCategories(req, res) {
try {
const categories = await listCategories();
res.json(categories);
} catch (error) {
console.error('Get public categories error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports.getPublicCategories = getPublicCategories;

View File

@@ -0,0 +1,414 @@
const { getDatabase } = require('../models/database');
const { scheduleEndpoint, stopScheduling } = require('../services/monitoringService');
const { validateEndpointUrl } = require('../middleware/auth');
const {
listEndpointsWithCategory,
listCategoriesOrdered,
getLatestCheckResult,
getUptimeSummary,
getEndpointById: getEndpointRecordById,
getRecentCheckResults,
createEndpointRecord,
updateEndpointRecord,
deleteEndpointRecord,
reorderEndpointRecords,
} = require('../data/endpointData');
async function getAllEndpoints(req, res) {
try {
const endpoints = await listEndpointsWithCategory();
const categories = await listCategoriesOrdered();
for (const endpoint of endpoints) {
const result = await getLatestCheckResult(endpoint.id);
endpoint.latest = result || null;
const uptimeRow = await getUptimeSummary(endpoint.id, 30);
endpoint.uptime_30d =
uptimeRow?.total > 0
? parseFloat(((uptimeRow.ups / uptimeRow.total) * 100).toFixed(2))
: null;
endpoint.total_checks_30d = uptimeRow?.total ?? 0;
endpoint.successful_checks_30d = uptimeRow?.ups ?? 0;
}
res.json({ endpoints, categories });
} catch (error) {
console.error('Get endpoints error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getEndpointById(req, res) {
try {
const db = getDatabase();
const endpoint = await getEndpointRecordById(req.params.id);
if (!endpoint) {
return res.status(404).json({ error: 'Endpoint not found' });
}
const results = await getRecentCheckResults(endpoint.id, 100);
endpoint.latest = results[0] || null;
// 30d uptime summary for endpoint detail hero / dashboard
const uptimeRow = await getUptimeSummary(endpoint.id, 30);
endpoint.uptime_30d =
uptimeRow?.total > 0
? parseFloat(((uptimeRow.ups / uptimeRow.total) * 100).toFixed(2))
: null;
endpoint.total_checks_30d = uptimeRow?.total ?? 0;
endpoint.successful_checks_30d = uptimeRow?.ups ?? 0;
endpoint.downtime_events_30d = Math.max(
0,
(uptimeRow?.total ?? 0) - (uptimeRow?.ups ?? 0)
);
let typeStats = null;
if (endpoint.type === 'tcp' || endpoint.type === 'ping') {
const statsRow = await db.get(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END) AS downs,
AVG(response_time) AS avg_rt,
MIN(response_time) AS min_rt,
MAX(response_time) AS max_rt,
SQRT(MAX(0, AVG(response_time * response_time) - AVG(response_time) * AVG(response_time))) AS jitter
FROM check_results
WHERE endpoint_id = ?
AND checked_at > datetime('now', '-24 hours')
AND response_time IS NOT NULL`,
[endpoint.id]
);
if (statsRow && statsRow.total > 0) {
typeStats = {
total: statsRow.total,
packet_loss: parseFloat(((statsRow.downs / statsRow.total) * 100).toFixed(1)),
avg_rt: Math.round(statsRow.avg_rt),
min_rt: statsRow.min_rt,
max_rt: statsRow.max_rt,
jitter: Math.round(statsRow.jitter ?? 0),
};
}
}
let pingStats = null;
if (endpoint.type === 'http' && endpoint.ping_enabled) {
const pingRow = await db.get(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN ping_response_time IS NULL THEN 1 ELSE 0 END) AS timeouts,
AVG(ping_response_time) AS avg_rt,
MIN(ping_response_time) AS min_rt,
MAX(ping_response_time) AS max_rt,
SQRT(MAX(0, AVG(ping_response_time * ping_response_time) - AVG(ping_response_time) * AVG(ping_response_time))) AS jitter
FROM check_results
WHERE endpoint_id = ?
AND checked_at > datetime('now', '-24 hours')`,
[endpoint.id]
);
if (pingRow && pingRow.total > 0) {
pingStats = {
total: pingRow.total,
packet_loss: parseFloat(((pingRow.timeouts / pingRow.total) * 100).toFixed(1)),
avg_rt: pingRow.avg_rt !== null ? Math.round(pingRow.avg_rt) : null,
min_rt: pingRow.min_rt,
max_rt: pingRow.max_rt,
jitter: pingRow.avg_rt !== null ? Math.round(pingRow.jitter ?? 0) : null,
};
}
}
res.json({ endpoint, results, typeStats, pingStats });
} catch (error) {
console.error('Get endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function createEndpoint(req, res) {
try {
const { name, url, type, interval, timeout, active, ping_enabled, category_id } = req.body;
if (!name || !url) {
return res.status(400).json({ error: 'Name and URL are required' });
}
const endpointType = type || 'http';
const urlError = await validateEndpointUrl(url, endpointType);
if (urlError) {
return res.status(400).json({ error: urlError });
}
const result = await createEndpointRecord({
name,
url,
type: endpointType,
interval: interval || 300,
timeout: timeout || 10,
active: active !== false ? 1 : 0,
ping_enabled: ping_enabled && endpointType === 'http' ? 1 : 0,
group_id: category_id || null,
});
const endpoint = await getEndpointRecordById(result.lastID);
if (endpoint.active) {
await scheduleEndpoint(endpoint);
}
res.status(201).json(endpoint);
} catch (error) {
console.error('Create endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateEndpoint(req, res) {
try {
const { name, url, type, interval, timeout, active, ping_enabled, category_id } = req.body;
if (url && type) {
const urlError = await validateEndpointUrl(url, type);
if (urlError) {
return res.status(400).json({ error: urlError });
}
await updateEndpointRecord(
req.params.id,
{
name,
url,
type,
interval,
timeout,
active: active ? 1 : 0,
ping_enabled: ping_enabled && type === 'http' ? 1 : 0,
group_id: category_id || null,
},
true
);
} else {
await updateEndpointRecord(
req.params.id,
{
name,
type,
interval,
timeout,
active: active ? 1 : 0,
ping_enabled: ping_enabled && type === 'http' ? 1 : 0,
group_id: category_id || null,
},
false
);
}
const endpoint = await getEndpointRecordById(req.params.id);
if (endpoint.active) {
await scheduleEndpoint(endpoint);
} else {
stopScheduling(endpoint.id);
}
res.json(endpoint);
} catch (error) {
console.error('Update endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function deleteEndpoint(req, res) {
try {
stopScheduling(parseInt(req.params.id, 10));
await deleteEndpointRecord(req.params.id);
res.json({ success: true });
} catch (error) {
console.error('Delete endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getUptime(req, res) {
try {
const db = getDatabase();
const days = parseInt(req.query.days, 10) || 30;
const endpointId = req.params.id;
const cutoffDate = new Date(
Date.now() - days * 24 * 60 * 60 * 1000
).toISOString();
const results = await db.all(
`SELECT status
FROM check_results
WHERE endpoint_id = ? AND checked_at > ?
ORDER BY checked_at ASC`,
[endpointId, cutoffDate]
);
const ups = results.filter((r) => r.status === 'up').length;
const total = results.length;
const uptime = total > 0 ? ((ups / total) * 100).toFixed(2) : 0;
res.json({ uptime, ups, total, days });
} catch (error) {
console.error('Get uptime error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getHistory(req, res) {
try {
const db = getDatabase();
const days = parseInt(req.query.days, 10) || 90;
const endpointId = req.params.id;
const endpoint = await db.get('SELECT id FROM endpoints WHERE id = ?', [endpointId]);
if (!endpoint) return res.status(404).json({ error: 'Endpoint not found' });
const rows = await db.all(
`SELECT
date(checked_at) AS day,
COUNT(*) AS total,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS ups
FROM check_results
WHERE endpoint_id = ?
AND checked_at > datetime('now', '-' || ? || ' days')
GROUP BY day
ORDER BY day ASC`,
[endpointId, days]
);
const data = rows.map((r) => ({
date: r.day,
uptime: r.total > 0 ? parseFloat(((r.ups / r.total) * 100).toFixed(2)) : null,
checks: r.total,
}));
res.json({ days, data });
} catch (error) {
console.error('Get history error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getResponseTimes(req, res) {
try {
const db = getDatabase();
const endpointId = req.params.id;
const hoursParam = req.query.hours ? parseInt(req.query.hours, 10) || null : null;
const daysParam = hoursParam ? null : parseInt(req.query.days, 10) || 30;
const endpoint = await db.get('SELECT id FROM endpoints WHERE id = ?', [endpointId]);
if (!endpoint) {
return res.status(404).json({ error: 'Endpoint not found' });
}
let useHourly;
let cutoffExpr;
let cutoffArgs;
if (hoursParam) {
useHourly = true;
cutoffExpr = `datetime('now', '-' || ? || ' hours')`;
cutoffArgs = [hoursParam];
} else {
cutoffExpr = `datetime('now', '-' || ? || ' days')`;
cutoffArgs = [daysParam];
const dayCount = await db.get(
`SELECT COUNT(DISTINCT date(checked_at)) AS cnt
FROM check_results
WHERE endpoint_id = ?
AND checked_at > ${cutoffExpr}
AND response_time IS NOT NULL`,
[endpointId, ...cutoffArgs]
);
useHourly = (dayCount?.cnt ?? 0) <= 2;
}
let rows;
if (useHourly) {
rows = await db.all(
`SELECT
strftime('%Y-%m-%dT%H:00:00', checked_at) AS day,
ROUND(AVG(response_time)) AS avg,
MIN(response_time) AS min,
MAX(response_time) AS max,
COUNT(*) AS checks
FROM check_results
WHERE endpoint_id = ?
AND checked_at > ${cutoffExpr}
AND response_time IS NOT NULL
GROUP BY strftime('%Y-%m-%d %H', checked_at)
ORDER BY day ASC`,
[endpointId, ...cutoffArgs]
);
} else {
rows = await db.all(
`SELECT
date(checked_at) AS day,
ROUND(AVG(response_time)) AS avg,
MIN(response_time) AS min,
MAX(response_time) AS max,
COUNT(*) AS checks
FROM check_results
WHERE endpoint_id = ?
AND checked_at > ${cutoffExpr}
AND response_time IS NOT NULL
GROUP BY day
ORDER BY day ASC`,
[endpointId, ...cutoffArgs]
);
}
res.json({
days: daysParam,
hours: hoursParam,
granularity: useHourly ? 'hour' : 'day',
data: rows,
});
} catch (error) {
console.error('Get response times error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function reorderEndpoints(req, res) {
try {
const { updates } = req.body;
if (!Array.isArray(updates) || updates.length === 0) {
return res.status(400).json({ error: 'updates array is required' });
}
await reorderEndpointRecords(updates);
res.json({ success: true });
} catch (error) {
console.error('Reorder endpoints error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getAllEndpoints,
getEndpointById,
createEndpoint,
updateEndpoint,
deleteEndpoint,
getUptime,
getHistory,
getResponseTimes,
reorderEndpoints,
};

View File

@@ -0,0 +1,380 @@
const { getDatabase, runInTransaction } = require('../models/database');
const {
listIncidentsOrdered,
getIncidentById: getIncidentRecordById,
listIncidentEndpoints,
getLatestIncidentUpdate,
listIncidentUpdates,
createIncidentRecord,
linkIncidentEndpoint,
createIncidentUpdate,
updateIncidentCore,
deleteIncidentLinksExceptSource,
deleteAllIncidentLinks,
markIncidentResolved,
setIncidentAdminManaged,
setIncidentStatus,
getIncidentUpdateById,
reopenIncidentRecord,
setIncidentPostMortem,
deleteIncidentRecord,
} = require('../data/incidentData');
const { queueIncidentNotification } = require('../services/notificationService');
// Helpers
async function enrichIncident(incident, { includeUpdates = false } = {}) {
incident.endpoints = await listIncidentEndpoints(incident.id);
// Always include the latest update for preview
incident.latest_update = await getLatestIncidentUpdate(incident.id);
if (includeUpdates) {
incident.updates = await listIncidentUpdates(incident.id);
}
return incident;
}
// Public
async function getAllIncidents(req, res) {
try {
const incidents = await listIncidentsOrdered();
for (const incident of incidents) {
await enrichIncident(incident);
}
res.json(incidents);
} catch (error) {
console.error('Get incidents error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getIncidentById(req, res) {
try {
const incident = await getIncidentRecordById(req.params.id);
if (!incident) return res.status(404).json({ error: 'Incident not found' });
await enrichIncident(incident, { includeUpdates: true });
res.json(incident);
} catch (error) {
console.error('Get incident error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Admin CRUD
async function createIncident(req, res) {
try {
const {
title,
description,
severity = 'degraded',
status = 'investigating',
source = 'manual',
endpoint_ids = [],
initial_message,
created_by = 'admin'
} = req.body;
if (!title || !severity) {
return res.status(400).json({ error: 'Title and severity are required' });
}
const incidentId = await runInTransaction(async () => {
const result = await createIncidentRecord({
title,
description: description || '',
severity,
status,
source,
});
for (const endpointId of endpoint_ids) {
await linkIncidentEndpoint(result.lastID, endpointId);
}
if (initial_message) {
await createIncidentUpdate(result.lastID, initial_message, status, created_by);
}
return result.lastID;
});
const incident = await getIncidentRecordById(incidentId);
await enrichIncident(incident, { includeUpdates: true });
await queueIncidentNotification('incident_created', incident.id, initial_message || incident.description || 'New incident created.');
res.status(201).json(incident);
} catch (error) {
console.error('Create incident error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateIncident(req, res) {
try {
const { title, description, severity, status, endpoint_ids = [] } = req.body;
const existing = await getIncidentRecordById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Incident not found' });
// Mark as admin-managed if a human is editing an auto-created incident
const adminManaged = existing.auto_created ? 1 : (existing.admin_managed || 0);
await runInTransaction(async () => {
await updateIncidentCore(req.params.id, {
title,
description: description || '',
severity,
status,
admin_managed: adminManaged,
});
if (existing.auto_created && existing.source_endpoint_id) {
await deleteIncidentLinksExceptSource(req.params.id, existing.source_endpoint_id);
for (const endpointId of endpoint_ids) {
await linkIncidentEndpoint(req.params.id, endpointId, true);
}
await linkIncidentEndpoint(req.params.id, existing.source_endpoint_id, true);
} else {
await deleteAllIncidentLinks(req.params.id);
for (const endpointId of endpoint_ids) {
await linkIncidentEndpoint(req.params.id, endpointId);
}
}
});
const incident = await getIncidentRecordById(req.params.id);
await enrichIncident(incident, { includeUpdates: true });
await queueIncidentNotification('incident_updated', incident.id, 'Incident details were updated.');
res.json(incident);
} catch (error) {
console.error('Update incident error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function resolveIncident(req, res) {
try {
const { message, created_by = 'admin' } = req.body;
const existing = await getIncidentRecordById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Incident not found' });
// Resolving an auto-incident manually marks it as admin-managed
const adminManaged = existing.auto_created ? 1 : (existing.admin_managed || 0);
const closingMessage = message || 'This incident has been resolved.';
await runInTransaction(async () => {
await markIncidentResolved(req.params.id, adminManaged);
await createIncidentUpdate(req.params.id, closingMessage, 'resolved', created_by);
});
const incident = await getIncidentRecordById(req.params.id);
await enrichIncident(incident, { includeUpdates: true });
await queueIncidentNotification('incident_resolved', incident.id, closingMessage);
res.json(incident);
} catch (error) {
console.error('Resolve incident error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function deleteIncident(req, res) {
try {
await deleteIncidentRecord(req.params.id);
res.json({ success: true });
} catch (error) {
console.error('Delete incident error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Incident Updates (timeline)
async function addIncidentUpdate(req, res) {
try {
const { message, status_label, created_by = 'admin' } = req.body;
if (!message) {
return res.status(400).json({ error: 'Message is required' });
}
const incident = await getIncidentRecordById(req.params.id);
if (!incident) return res.status(404).json({ error: 'Incident not found' });
const updateId = await runInTransaction(async () => {
if (incident.auto_created && !incident.admin_managed) {
await setIncidentAdminManaged(req.params.id, 1);
}
const result = await createIncidentUpdate(req.params.id, message, status_label || null, created_by);
if (status_label) {
await setIncidentStatus(req.params.id, status_label);
}
return result.lastID;
});
const update = await getIncidentUpdateById(updateId);
await queueIncidentNotification('incident_updated', req.params.id, message);
res.status(201).json(update);
} catch (error) {
console.error('Add incident update error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Maintenance Windows
async function getAllMaintenance(req, res) {
try {
const db = getDatabase();
const windows = await db.all(`
SELECT mw.*, e.name AS endpoint_name
FROM maintenance_windows mw
LEFT JOIN endpoints e ON e.id = mw.endpoint_id
ORDER BY mw.start_time DESC
`);
res.json(windows);
} catch (error) {
console.error('Get maintenance error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function createMaintenance(req, res) {
try {
const { title, description, endpoint_id, start_time, end_time } = req.body;
if (!title || !start_time || !end_time) {
return res.status(400).json({ error: 'Title, start_time, and end_time are required' });
}
const db = getDatabase();
const result = await db.run(
`INSERT INTO maintenance_windows (title, description, endpoint_id, start_time, end_time)
VALUES (?, ?, ?, ?, ?)`,
[title, description || '', endpoint_id || null, start_time, end_time]
);
const window = await db.get('SELECT * FROM maintenance_windows WHERE id = ?', [result.lastID]);
res.status(201).json(window);
} catch (error) {
console.error('Create maintenance error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateMaintenance(req, res) {
try {
const { title, description, endpoint_id, start_time, end_time } = req.body;
const db = getDatabase();
await db.run(
`UPDATE maintenance_windows
SET title = ?, description = ?, endpoint_id = ?, start_time = ?, end_time = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[title, description || '', endpoint_id || null, start_time, end_time, req.params.id]
);
const window = await db.get('SELECT * FROM maintenance_windows WHERE id = ?', [req.params.id]);
res.json(window);
} catch (error) {
console.error('Update maintenance error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function deleteMaintenance(req, res) {
try {
const db = getDatabase();
await db.run('DELETE FROM maintenance_windows WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('Delete maintenance error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Reopen
async function reopenIncident(req, res) {
try {
const incident = await getIncidentRecordById(req.params.id);
if (!incident) return res.status(404).json({ error: 'Incident not found' });
if (!incident.resolved_at) {
return res.status(400).json({ error: 'Incident is not resolved' });
}
// Admins may re-open within 7 days of resolution
const REOPEN_WINDOW_DAYS = 7;
const resolvedAt = new Date(incident.resolved_at.replace(' ', 'T') + 'Z');
const ageMs = Date.now() - resolvedAt.getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays > REOPEN_WINDOW_DAYS) {
return res.status(403).json({
error: `This incident can no longer be re-opened. The ${REOPEN_WINDOW_DAYS}-day re-open window has expired.`,
expired: true,
});
}
const { message = '', created_by = 'admin' } = req.body;
await runInTransaction(async () => {
await reopenIncidentRecord(req.params.id);
await createIncidentUpdate(
req.params.id,
message || 'This incident has been re-opened by an administrator.',
'investigating',
created_by
);
});
const updated = await getIncidentRecordById(req.params.id);
await enrichIncident(updated, { includeUpdates: true });
await queueIncidentNotification('incident_updated', updated.id, message || 'Incident re-opened by administrator.');
res.json(updated);
} catch (error) {
console.error('Reopen incident error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
// Post-mortem
async function setPostMortem(req, res) {
try {
const { post_mortem } = req.body;
const incident = await getIncidentRecordById(req.params.id);
if (!incident) return res.status(404).json({ error: 'Incident not found' });
await setIncidentPostMortem(req.params.id, post_mortem || null);
const updated = await getIncidentRecordById(req.params.id);
await enrichIncident(updated, { includeUpdates: true });
res.json(updated);
} catch (error) {
console.error('Set post-mortem error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getAllIncidents,
getIncidentById,
createIncident,
updateIncident,
resolveIncident,
deleteIncident,
addIncidentUpdate,
reopenIncident,
setPostMortem,
getAllMaintenance,
createMaintenance,
updateMaintenance,
deleteMaintenance,
};

View File

@@ -0,0 +1,275 @@
const { getDatabase, runInTransaction } = require('../models/database');
const { verifySmtpConnection, sendMail } = require('../services/smtpService');
const { renderTemplate } = require('../services/notificationTemplates');
const {
getNotificationDefaults,
setNotificationDefaults,
getNotificationHealth,
ensureUserNotificationDefaults,
} = require('../services/notificationService');
async function getMyNotificationPreferences(req, res) {
try {
const db = getDatabase();
await ensureUserNotificationDefaults(req.user.id, req.user.role);
const allScope = await db.get(
`SELECT * FROM email_notifications
WHERE user_id = ? AND scope_type = 'all' AND endpoint_id IS NULL AND category_id IS NULL
LIMIT 1`,
[req.user.id]
);
const scoped = await db.all(
`SELECT scope_type, endpoint_id, category_id
FROM email_notifications
WHERE user_id = ? AND scope_type IN ('endpoint', 'category') AND active = 1`,
[req.user.id]
);
res.json({
notifyOnDown: Number(allScope?.notify_on_down || 0) === 1,
notifyOnDegraded: Number(allScope?.notify_on_degraded || 0) === 1,
notifyOnRecovered: Number(allScope?.notify_on_recovery || 0) === 1,
notifyOnIncident: Number(allScope?.notify_on_incident || 0) === 1,
scope: scoped.length > 0 ? 'selected' : 'all',
selectedEndpointIds: scoped.filter((row) => row.scope_type === 'endpoint').map((row) => row.endpoint_id),
selectedCategoryIds: scoped.filter((row) => row.scope_type === 'category').map((row) => row.category_id),
});
} catch (error) {
console.error('Get notification preferences error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateMyNotificationPreferences(req, res) {
try {
const {
notifyOnDown,
notifyOnDegraded,
notifyOnRecovered,
notifyOnIncident,
scope,
selectedEndpointIds = [],
selectedCategoryIds = [],
} = req.body;
await runInTransaction(async (db) => {
await ensureUserNotificationDefaults(req.user.id, req.user.role);
await db.run(
`UPDATE email_notifications
SET notify_on_down = ?, notify_on_degraded = ?, notify_on_recovery = ?, notify_on_incident = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND scope_type = 'all' AND endpoint_id IS NULL AND category_id IS NULL`,
[
notifyOnDown ? 1 : 0,
notifyOnDegraded ? 1 : 0,
notifyOnRecovered ? 1 : 0,
notifyOnIncident ? 1 : 0,
req.user.id,
]
);
await db.run(
`DELETE FROM email_notifications
WHERE user_id = ? AND scope_type IN ('endpoint', 'category')`,
[req.user.id]
);
if (scope === 'selected') {
for (const endpointId of selectedEndpointIds) {
await db.run(
`INSERT INTO email_notifications
(user_id, endpoint_id, category_id, scope_type, notify_on_down, notify_on_recovery, notify_on_degraded, notify_on_incident, active)
VALUES (?, ?, NULL, 'endpoint', 1, 1, 1, 1, 1)`,
[req.user.id, endpointId]
);
}
for (const categoryId of selectedCategoryIds) {
await db.run(
`INSERT INTO email_notifications
(user_id, endpoint_id, category_id, scope_type, notify_on_down, notify_on_recovery, notify_on_degraded, notify_on_incident, active)
VALUES (?, NULL, ?, 'category', 1, 1, 1, 1, 1)`,
[req.user.id, categoryId]
);
}
}
});
res.json({ success: true });
} catch (error) {
console.error('Update notification preferences error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function sendSmtpTestEmail(req, res) {
try {
const { to } = req.body;
if (!to || !/^\S+@\S+\.\S+$/.test(to)) {
return res.status(400).json({ error: 'A valid recipient email is required.' });
}
await verifySmtpConnection();
// Use configured public URL from settings
const { getSettingsMap } = require('../services/settingsService');
const settings = await getSettingsMap();
const publicUrl = String(settings.publicUrl || process.env.PUBLIC_STATUS_PAGE_URL || process.env.FRONTEND_URL || 'http://localhost:3000');
const template = renderTemplate('incident_updated', {
incident: {
title: 'SMTP Test Notification',
status: 'test',
},
message: 'SMTP credentials are valid and outbound email delivery is working.',
timestamp: new Date().toISOString(),
statusPageUrl: publicUrl,
});
await sendMail({ to, subject: template.subject, text: template.text, html: template.html });
res.json({ success: true, message: 'Test email sent successfully.' });
} catch (error) {
console.error('Send test email error:', error);
res.status(400).json({ error: error.message || 'Failed to send test email.' });
}
}
async function listExtraRecipients(req, res) {
try {
const db = getDatabase();
const recipients = await db.all(
'SELECT id, email, name, active, created_at, updated_at FROM notification_extra_recipients ORDER BY created_at DESC'
);
res.json(recipients);
} catch (error) {
console.error('List extra recipients error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function createExtraRecipient(req, res) {
try {
const { email, name } = req.body;
if (!email || !/^\S+@\S+\.\S+$/.test(email)) {
return res.status(400).json({ error: 'A valid email is required.' });
}
const db = getDatabase();
const result = await db.run(
'INSERT INTO notification_extra_recipients (email, name, active, updated_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP)',
[email.trim().toLowerCase(), (name || '').trim() || null]
);
const row = await db.get('SELECT id, email, name, active, created_at, updated_at FROM notification_extra_recipients WHERE id = ?', [result.lastID]);
res.status(201).json(row);
} catch (error) {
console.error('Create extra recipient error:', error);
if (String(error.message || '').includes('UNIQUE')) {
return res.status(400).json({ error: 'Recipient already exists.' });
}
res.status(500).json({ error: 'Internal server error' });
}
}
async function deleteExtraRecipient(req, res) {
try {
const db = getDatabase();
await db.run('DELETE FROM notification_extra_recipients WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('Delete extra recipient error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getDeliveryLogs(req, res) {
try {
const db = getDatabase();
const filters = [];
const params = [];
if (req.query.endpointId) {
filters.push('d.endpoint_id = ?');
params.push(Number(req.query.endpointId));
}
if (req.query.eventType) {
filters.push('d.event_type = ?');
params.push(req.query.eventType);
}
if (req.query.status) {
filters.push('d.status = ?');
params.push(req.query.status);
}
if (req.query.fromDate) {
filters.push('datetime(d.created_at) >= datetime(?)');
params.push(req.query.fromDate);
}
if (req.query.toDate) {
filters.push('datetime(d.created_at) <= datetime(?)');
params.push(req.query.toDate);
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
const limit = Math.min(200, Math.max(20, Number(req.query.limit) || 100));
params.push(limit);
const rows = await db.all(
`SELECT d.*, e.name AS endpoint_name
FROM notification_deliveries d
LEFT JOIN endpoints e ON e.id = d.endpoint_id
${whereClause}
ORDER BY d.created_at DESC
LIMIT ?`,
params
);
res.json(rows);
} catch (error) {
console.error('Get delivery logs error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getNotificationDefaultsController(req, res) {
try {
res.json(await getNotificationDefaults());
} catch (error) {
console.error('Get notification defaults error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateNotificationDefaultsController(req, res) {
try {
await setNotificationDefaults(req.body || {});
res.json({ success: true });
} catch (error) {
console.error('Update notification defaults error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getSmtpHealthController(req, res) {
try {
res.json(await getNotificationHealth());
} catch (error) {
console.error('Get SMTP health error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getMyNotificationPreferences,
updateMyNotificationPreferences,
sendSmtpTestEmail,
listExtraRecipients,
createExtraRecipient,
deleteExtraRecipient,
getDeliveryLogs,
getNotificationDefaultsController,
updateNotificationDefaultsController,
getSmtpHealthController,
};

View File

@@ -0,0 +1,127 @@
const { getDatabase } = require('../models/database');
const bcrypt = require('bcryptjs');
const { validatePassword } = require('../middleware/auth');
async function getUserProfile(req, res) {
try {
const userId = req.user.id;
const db = getDatabase();
const user = await db.get(
'SELECT id, name, email, role FROM users WHERE id = ?',
[userId]
);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
console.error('Get profile error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateUserProfile(req, res) {
try {
const userId = req.user.id;
const { name, email } = req.body;
const db = getDatabase();
if (!name && !email) {
return res.status(400).json({ error: 'At least one field is required' });
}
// Check if new email already exists
if (email) {
const existing = await db.get(
'SELECT id FROM users WHERE email = ? AND id != ?',
[email, userId]
);
if (existing) {
return res.status(400).json({ error: 'Email already in use' });
}
}
const updateFields = [];
const updateValues = [];
if (name) {
updateFields.push('name = ?');
updateValues.push(name);
}
if (email) {
updateFields.push('email = ?');
updateValues.push(email);
}
updateValues.push(userId);
const query = `UPDATE users SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
await db.run(query, updateValues);
const updated = await db.get(
'SELECT id, name, email, role FROM users WHERE id = ?',
[userId]
);
res.json({ success: true, user: updated });
} catch (error) {
console.error('Update profile error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function changePassword(req, res) {
try {
const userId = req.user.id;
const { currentPassword, newPassword, confirmPassword } = req.body;
const db = getDatabase();
if (!currentPassword || !newPassword || !confirmPassword) {
return res.status(400).json({ error: 'All password fields are required' });
}
// Validate password strength
const passwordErrors = validatePassword(newPassword);
if (passwordErrors.length > 0) {
return res.status(400).json({ error: passwordErrors.join('. ') });
}
if (newPassword !== confirmPassword) {
return res.status(400).json({ error: 'New passwords do not match' });
}
// Get current user
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [userId]);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const isValid = await bcrypt.compare(currentPassword, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Hash and update new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
await db.run(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[hashedPassword, userId]
);
res.json({ success: true, message: 'Password changed successfully' });
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getUserProfile,
updateUserProfile,
changePassword
};

View File

@@ -0,0 +1,228 @@
const bcrypt = require('bcryptjs');
const { getDatabase, isSetupComplete } = require('../models/database');
const { validatePassword } = require('../middleware/auth');
const { setSettings, getSettingsMap, getSmtpConfig, saveSmtpConfig } = require('../services/settingsService');
const { ensureUserNotificationDefaults } = require('../services/notificationService');
async function getSetupStatus(req, res) {
try {
const complete = await isSetupComplete();
if (complete) {
return res.status(400).json({ error: 'Setup already completed' });
}
res.json({ setupRequired: true });
} catch (error) {
console.error('Get setup status error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function completeSetup(req, res) {
try {
const {
title,
adminName,
adminEmail,
adminPassword,
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpFromEmail,
smtpFromName,
smtpTlsMode,
smtpTimeoutMs,
publicUrl,
} = req.body;
if (!title || !adminName || !adminEmail || !adminPassword) {
return res.status(400).json({ error: 'Title and admin credentials are required' });
}
// Validate admin password strength
const passwordErrors = validatePassword(adminPassword);
if (passwordErrors.length > 0) {
return res.status(400).json({ error: passwordErrors.join('. ') });
}
const db = getDatabase();
// Check if setup is already complete
const complete = await isSetupComplete();
if (complete) {
return res.status(400).json({ error: 'Setup has already been completed' });
}
// Hash admin password
const hashedPassword = await bcrypt.hash(adminPassword, 10);
// Create admin user
await db.run(
'INSERT INTO users (email, password_hash, name, role, active) VALUES (?, ?, ?, ?, ?)',
[adminEmail, hashedPassword, adminName, 'admin', 1]
);
await setSettings({
title,
logoUrl: '',
primaryColor: '#6366f1',
secondaryColor: '#8b5cf6',
publicUrl: publicUrl || process.env.FRONTEND_URL || 'http://localhost:3000',
});
const smtpResult = await saveSmtpConfig(
{
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpFromEmail,
smtpFromName,
smtpTlsMode,
smtpTimeoutMs,
},
{ preservePassword: false, allowEmpty: true }
);
if (!smtpResult.success) {
return res.status(400).json({ error: smtpResult.error });
}
// Generate JWT for admin user
const jwt = require('jsonwebtoken');
const user = await db.get('SELECT * FROM users WHERE email = ?', [adminEmail]);
await ensureUserNotificationDefaults(user.id, 'admin');
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
success: true,
message: 'Setup completed successfully',
token,
user: { id: user.id, email: user.email, name: user.name, role: user.role }
});
} catch (error) {
console.error('Complete setup error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getSiteSettings(req, res) {
try {
const settings = await getSettingsMap();
const result = {};
// Sensitive keys that should not be exposed publicly
const sensitiveKeys = [
'smtpPassword',
'smtpUser',
'smtpHost',
'smtpPort',
'smtpFromEmail',
'smtpFromName',
'smtpTlsMode',
'smtpTimeoutMs',
'smtpLastError',
'smtpLastErrorAt',
'smtpFailureStreak',
'smtpLastSuccessAt',
];
Object.keys(settings).forEach((key) => {
if (sensitiveKeys.includes(key)) return;
result[key] = settings[key];
});
res.json(result);
} catch (error) {
console.error('Get settings error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function updateSettings(req, res) {
try {
const {
title,
publicUrl,
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpFromEmail,
smtpFromName,
smtpTlsMode,
smtpTimeoutMs,
} = req.body;
if (title) {
await setSettings({ title });
}
if (publicUrl !== undefined) {
await setSettings({ publicUrl: String(publicUrl) });
}
const hasSmtpFields = [
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpFromEmail,
smtpFromName,
smtpTlsMode,
smtpTimeoutMs,
].some((value) => value !== undefined);
if (hasSmtpFields) {
const smtpResult = await saveSmtpConfig(
{
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpFromEmail,
smtpFromName,
smtpTlsMode,
smtpTimeoutMs,
},
{ preservePassword: true, allowEmpty: true }
);
if (!smtpResult.success) {
return res.status(400).json({ error: smtpResult.error });
}
}
res.json({ success: true, message: 'Settings updated' });
} catch (error) {
console.error('Update settings error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getAdminSettings(req, res) {
try {
const settings = await getSettingsMap();
const smtp = await getSmtpConfig({ includePassword: false });
res.json({
...settings,
...smtp,
smtpPassword: '',
});
} catch (error) {
console.error('Get admin settings error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getSetupStatus,
completeSetup,
getSiteSettings,
updateSettings,
getAdminSettings,
};

View File

@@ -0,0 +1,106 @@
const bcrypt = require('bcryptjs');
const { getDatabase } = require('../models/database');
const { validatePassword } = require('../middleware/auth');
const { ensureUserNotificationDefaults } = require('../services/notificationService');
const ALLOWED_ROLES = ['admin', 'viewer'];
async function getAllUsers(req, res) {
try {
const db = getDatabase();
const users = await db.all('SELECT id, name, email, role, active, created_at FROM users');
res.json(users || []);
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function createUser(req, res) {
try {
const { name, email, password, role } = req.body;
if (!name || !email || !password || !role) {
return res.status(400).json({ error: 'Name, email, password, and role are required' });
}
// Validate role
if (!ALLOWED_ROLES.includes(role)) {
return res.status(400).json({ error: `Invalid role. Allowed roles: ${ALLOWED_ROLES.join(', ')}` });
}
// Validate password strength
const passwordErrors = validatePassword(password);
if (passwordErrors.length > 0) {
return res.status(400).json({ error: passwordErrors.join('. ') });
}
const db = getDatabase();
// Check if user already exists
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(400).json({ error: 'User with this email already exists' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const result = await db.run(
'INSERT INTO users (email, password_hash, name, role, active) VALUES (?, ?, ?, ?, ?)',
[email, hashedPassword, name, role, 1]
);
const newUser = await db.get(
'SELECT id, name, email, role, active FROM users WHERE id = ?',
[result.lastID]
);
await ensureUserNotificationDefaults(newUser.id, newUser.role);
res.status(201).json(newUser);
} catch (error) {
console.error('Create user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function deleteUser(req, res) {
try {
const { id } = req.params;
const db = getDatabase();
// Prevent deleting the logged-in user's own account
if (req.user.id === parseInt(id)) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
// Prevent deleting the last user
const totalUsers = await db.get('SELECT COUNT(*) as count FROM users');
if (totalUsers.count <= 1) {
return res.status(400).json({ error: 'Cannot delete the last user' });
}
// Prevent deleting the last admin, would leave no one able to manage the system
const targetUser = await db.get('SELECT role FROM users WHERE id = ?', [id]);
if (targetUser && targetUser.role === 'admin') {
const adminCount = await db.get("SELECT COUNT(*) as count FROM users WHERE role = 'admin'");
if (adminCount.count <= 1) {
return res.status(400).json({ error: 'Cannot delete the last admin user' });
}
}
await db.run('DELETE FROM users WHERE id = ?', [id]);
res.json({ success: true, message: 'User deleted' });
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getAllUsers,
createUser,
deleteUser
};

View File

@@ -0,0 +1,196 @@
/**
* v1Controller.js
*
* Implements the /api/v1 public status API using a format based on the
* Atlassian Statuspage API the closest to a universal status page standard.
*
* Status indicators (internal → universal):
* up → operational
* degraded → degraded_performance
* down → major_outage
* unknown/null → unknown
* maintenance → under_maintenance
*
* Overall page indicator:
* none → all operational
* minor → at least one degraded_performance
* critical → at least one major_outage
*/
const {
getPageMetaRow,
getActiveMaintenanceEndpointIds,
listComponentsWithLatestAndUptime,
listV1Incidents,
listScheduledMaintenances,
} = require('../data/v1Data');
function toUniversalStatus(internalStatus, inMaintenance = false) {
if (inMaintenance) return 'under_maintenance';
switch (internalStatus) {
case 'up': return 'operational';
case 'degraded': return 'degraded_performance';
case 'down': return 'major_outage';
default: return 'unknown';
}
}
function overallIndicator(components) {
if (components.some(c => c.status === 'major_outage')) return { indicator: 'critical', description: 'Major System Outage' };
if (components.some(c => c.status === 'degraded_performance')) return { indicator: 'minor', description: 'Partially Degraded Service' };
if (components.some(c => c.status === 'under_maintenance')) return { indicator: 'maintenance', description: 'Under Maintenance' };
if (components.some(c => c.status === 'unknown')) return { indicator: 'none', description: 'System Status Unknown' };
return { indicator: 'none', description: 'All Systems Operational' };
}
async function getPageMeta() {
const rows = await getPageMetaRow();
const meta = {};
for (const r of rows) meta[r.key] = r.value;
return {
id: 'status',
name: meta.title || 'Status Page',
url: meta.site_url || null,
updated_at: new Date().toISOString()
};
}
async function buildComponents({ allowedIds = null, authed = false } = {}) {
const maintenanceIds = await getActiveMaintenanceEndpointIds();
const rows = await listComponentsWithLatestAndUptime(allowedIds);
const components = [];
for (const row of rows) {
const inMaintenance = maintenanceIds.has(row.id);
const univStatus = toUniversalStatus(row.latest_status, inMaintenance);
const component = {
id: String(row.id),
name: row.name,
status: univStatus,
group_id: row.group_id ? String(row.group_id) : null,
is_group: false,
updated_at: row.latest_checked_at || row.updated_at,
};
if (authed) {
component.response_time_ms = row.latest_response_time ?? null;
component.uptime_30d_pct = row.uptime_total > 0
? parseFloat(((row.uptime_ups / row.uptime_total) * 100).toFixed(4))
: null;
component.uptime_30d_checks = row.uptime_total;
component.uptime_30d_up = row.uptime_ups;
}
components.push(component);
}
return { components, component_groups: [] };
}
async function getStatusJson(req, res) {
try {
const [page, { components }] = await Promise.all([
getPageMeta(),
buildComponents()
]);
res.json({
page,
components,
component_groups: [],
incidents: [],
scheduled_maintenances: []
});
} catch (error) {
console.error('getStatusJson error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getSummary(req, res) {
try {
const { components } = await buildComponents();
const indicator = overallIndicator(components);
res.json({ indicator: indicator.indicator, description: indicator.description });
} catch (error) {
console.error('getSummary error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getComponents(req, res) {
try {
const { components, component_groups } = await buildComponents({ authed: !!req.user });
res.json({ components, component_groups });
} catch (error) {
console.error('getComponents error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getComponentById(req, res) {
try {
const { components } = await buildComponents({ allowedIds: [parseInt(req.params.id, 10)], authed: !!req.user });
const component = components[0];
if (!component) {
return res.status(404).json({ error: 'Component not found' });
}
res.json(component);
} catch (error) {
console.error('getComponentById error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getIncidents(req, res) {
try {
const incidents = await listV1Incidents(false);
res.json(incidents);
} catch (error) {
console.error('getIncidents error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getIncidentById(req, res) {
try {
const incidents = await listV1Incidents(false);
const incident = incidents.find(i => i.id === parseInt(req.params.id, 10));
if (!incident) {
return res.status(404).json({ error: 'Incident not found' });
}
res.json(incident);
} catch (error) {
console.error('getIncidentById error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function getScheduledMaintenances(req, res) {
try {
const windows = await listScheduledMaintenances();
for (const w of windows) {
w.endpoints = w.endpoint_id
? [{ id: w.endpoint_id, name: w.endpoint_name || null }]
: [];
delete w.endpoint_name;
delete w.endpoint_id;
}
res.json(windows);
} catch (error) {
console.error('getScheduledMaintenances error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = {
getStatusJson,
getSummary,
getComponents,
getComponentById,
getIncidents,
getIncidentById,
getScheduledMaintenances
};

View File

@@ -0,0 +1,73 @@
const { getDatabase, runInTransaction } = require('../models/database');
async function listCategories() {
const db = getDatabase();
return db.all('SELECT * FROM endpoint_groups ORDER BY sort_order ASC, id ASC');
}
async function getCategoryById(categoryId) {
const db = getDatabase();
return db.get('SELECT * FROM endpoint_groups WHERE id = ?', [categoryId]);
}
async function getCategoryEndpointCount(categoryId) {
const db = getDatabase();
return db.get('SELECT COUNT(*) as count FROM endpoints WHERE group_id = ?', [categoryId]);
}
async function listEndpointsForCategory(categoryId) {
const db = getDatabase();
return db.all('SELECT * FROM endpoints WHERE group_id = ? ORDER BY name ASC', [categoryId]);
}
async function createCategoryRecord(name, description, sortOrder) {
const db = getDatabase();
return db.run(
'INSERT INTO endpoint_groups (name, description, sort_order) VALUES (?, ?, ?)',
[name, description || null, sortOrder]
);
}
async function getMaxCategorySortOrder() {
const db = getDatabase();
return db.get('SELECT MAX(sort_order) as max FROM endpoint_groups');
}
async function updateCategoryRecord(categoryId, name, description) {
const db = getDatabase();
return db.run(
'UPDATE endpoint_groups SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[name, description || null, categoryId]
);
}
async function clearCategoryFromEndpoints(categoryId) {
const db = getDatabase();
return db.run('UPDATE endpoints SET group_id = NULL WHERE group_id = ?', [categoryId]);
}
async function deleteCategoryRecord(categoryId) {
const db = getDatabase();
return db.run('DELETE FROM endpoint_groups WHERE id = ?', [categoryId]);
}
async function reorderCategoryRecords(order) {
await runInTransaction(async (db) => {
for (let i = 0; i < order.length; i++) {
await db.run('UPDATE endpoint_groups SET sort_order = ? WHERE id = ?', [i, order[i]]);
}
});
}
module.exports = {
listCategories,
getCategoryById,
getCategoryEndpointCount,
listEndpointsForCategory,
createCategoryRecord,
getMaxCategorySortOrder,
updateCategoryRecord,
clearCategoryFromEndpoints,
deleteCategoryRecord,
reorderCategoryRecords,
};

View File

@@ -0,0 +1,146 @@
const { getDatabase, runInTransaction } = require('../models/database');
async function listEndpointsWithCategory() {
const db = getDatabase();
return db.all(
`SELECT e.*, g.name as category_name, g.sort_order as category_order
FROM endpoints e
LEFT JOIN endpoint_groups g ON e.group_id = g.id
ORDER BY COALESCE(g.sort_order, 99999) ASC, COALESCE(e.sort_order, 99999) ASC, e.name ASC`
);
}
async function listCategoriesOrdered() {
const db = getDatabase();
return db.all('SELECT * FROM endpoint_groups ORDER BY sort_order ASC, id ASC');
}
async function getLatestCheckResult(endpointId) {
const db = getDatabase();
return db.get(
`SELECT status, response_time, checked_at
FROM check_results
WHERE endpoint_id = ?
ORDER BY checked_at DESC
LIMIT 1`,
[endpointId]
);
}
async function getUptimeSummary(endpointId, days = 30) {
const db = getDatabase();
return db.get(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS ups
FROM check_results
WHERE endpoint_id = ?
AND checked_at > datetime('now', '-' || ? || ' days')`,
[endpointId, days]
);
}
async function getEndpointById(endpointId) {
const db = getDatabase();
return db.get('SELECT * FROM endpoints WHERE id = ?', [endpointId]);
}
async function getRecentCheckResults(endpointId, limit = 100) {
const db = getDatabase();
return db.all(
`SELECT *
FROM check_results
WHERE endpoint_id = ?
ORDER BY checked_at DESC
LIMIT ?`,
[endpointId, limit]
);
}
async function createEndpointRecord(payload) {
const db = getDatabase();
return db.run(
`INSERT INTO endpoints
(name, url, type, interval, timeout, active, ping_enabled, group_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
payload.name,
payload.url,
payload.type,
payload.interval,
payload.timeout,
payload.active,
payload.ping_enabled,
payload.group_id,
]
);
}
async function updateEndpointRecord(endpointId, payload, includeUrl = true) {
const db = getDatabase();
if (includeUrl) {
return db.run(
`UPDATE endpoints
SET name = ?, url = ?, type = ?, interval = ?, timeout = ?, active = ?, ping_enabled = ?, group_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
payload.name,
payload.url,
payload.type,
payload.interval,
payload.timeout,
payload.active,
payload.ping_enabled,
payload.group_id,
endpointId,
]
);
}
return db.run(
`UPDATE endpoints
SET name = ?, type = ?, interval = ?, timeout = ?, active = ?, ping_enabled = ?, group_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
payload.name,
payload.type,
payload.interval,
payload.timeout,
payload.active,
payload.ping_enabled,
payload.group_id,
endpointId,
]
);
}
async function deleteEndpointRecord(endpointId) {
const db = getDatabase();
return db.run('DELETE FROM endpoints WHERE id = ?', [endpointId]);
}
async function reorderEndpointRecords(updates) {
await runInTransaction(async (db) => {
for (const { id, sort_order, category_id } of updates) {
await db.run(
`UPDATE endpoints
SET sort_order = ?, group_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[sort_order, category_id ?? null, id]
);
}
});
}
module.exports = {
listEndpointsWithCategory,
listCategoriesOrdered,
getLatestCheckResult,
getUptimeSummary,
getEndpointById,
getRecentCheckResults,
createEndpointRecord,
updateEndpointRecord,
deleteEndpointRecord,
reorderEndpointRecords,
};

View File

@@ -0,0 +1,177 @@
const { getDatabase } = require('../models/database');
async function listIncidentsOrdered() {
const db = getDatabase();
return db.all(
`SELECT * FROM incidents
ORDER BY
CASE WHEN resolved_at IS NULL THEN 0 ELSE 1 END ASC,
start_time DESC`
);
}
async function getIncidentById(incidentId) {
const db = getDatabase();
return db.get('SELECT * FROM incidents WHERE id = ?', [incidentId]);
}
async function listIncidentEndpoints(incidentId) {
const db = getDatabase();
return db.all(
`SELECT e.id, e.name, e.url, e.type FROM endpoints e
JOIN incident_endpoints ie ON e.id = ie.endpoint_id
WHERE ie.incident_id = ?`,
[incidentId]
);
}
async function getLatestIncidentUpdate(incidentId) {
const db = getDatabase();
return db.get(
`SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at DESC LIMIT 1`,
[incidentId]
);
}
async function listIncidentUpdates(incidentId) {
const db = getDatabase();
return db.all(
`SELECT * FROM incident_updates WHERE incident_id = ? ORDER BY created_at ASC`,
[incidentId]
);
}
async function createIncidentRecord(payload) {
const db = getDatabase();
return db.run(
`INSERT INTO incidents (title, description, severity, status, source, auto_created)
VALUES (?, ?, ?, ?, ?, 0)`,
[payload.title, payload.description, payload.severity, payload.status, payload.source]
);
}
async function linkIncidentEndpoint(incidentId, endpointId, ignoreConflicts = false) {
const db = getDatabase();
if (ignoreConflicts) {
return db.run(
'INSERT OR IGNORE INTO incident_endpoints (incident_id, endpoint_id) VALUES (?, ?)',
[incidentId, endpointId]
);
}
return db.run('INSERT INTO incident_endpoints (incident_id, endpoint_id) VALUES (?, ?)', [incidentId, endpointId]);
}
async function createIncidentUpdate(incidentId, message, statusLabel, createdBy) {
const db = getDatabase();
return db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by)
VALUES (?, ?, ?, ?)`,
[incidentId, message, statusLabel, createdBy]
);
}
async function updateIncidentCore(incidentId, payload) {
const db = getDatabase();
return db.run(
`UPDATE incidents
SET title = ?, description = ?, severity = ?, status = ?,
admin_managed = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[payload.title, payload.description, payload.severity, payload.status, payload.admin_managed, incidentId]
);
}
async function deleteIncidentLinksExceptSource(incidentId, sourceEndpointId) {
const db = getDatabase();
return db.run(
'DELETE FROM incident_endpoints WHERE incident_id = ? AND endpoint_id != ?',
[incidentId, sourceEndpointId]
);
}
async function deleteAllIncidentLinks(incidentId) {
const db = getDatabase();
return db.run('DELETE FROM incident_endpoints WHERE incident_id = ?', [incidentId]);
}
async function markIncidentResolved(incidentId, adminManaged) {
const db = getDatabase();
return db.run(
`UPDATE incidents
SET resolved_at = CURRENT_TIMESTAMP,
status = 'resolved',
admin_managed = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[adminManaged, incidentId]
);
}
async function setIncidentAdminManaged(incidentId, adminManaged = 1) {
const db = getDatabase();
return db.run(
`UPDATE incidents SET admin_managed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[adminManaged, incidentId]
);
}
async function setIncidentStatus(incidentId, status) {
const db = getDatabase();
return db.run(
`UPDATE incidents SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[status, incidentId]
);
}
async function getIncidentUpdateById(updateId) {
const db = getDatabase();
return db.get('SELECT * FROM incident_updates WHERE id = ?', [updateId]);
}
async function reopenIncidentRecord(incidentId) {
const db = getDatabase();
return db.run(
`UPDATE incidents
SET resolved_at = NULL,
status = 'investigating',
admin_managed = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[incidentId]
);
}
async function setIncidentPostMortem(incidentId, postMortem) {
const db = getDatabase();
return db.run(
`UPDATE incidents SET post_mortem = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[postMortem || null, incidentId]
);
}
async function deleteIncidentRecord(incidentId) {
const db = getDatabase();
return db.run('DELETE FROM incidents WHERE id = ?', [incidentId]);
}
module.exports = {
listIncidentsOrdered,
getIncidentById,
listIncidentEndpoints,
getLatestIncidentUpdate,
listIncidentUpdates,
createIncidentRecord,
linkIncidentEndpoint,
createIncidentUpdate,
updateIncidentCore,
deleteIncidentLinksExceptSource,
deleteAllIncidentLinks,
markIncidentResolved,
setIncidentAdminManaged,
setIncidentStatus,
getIncidentUpdateById,
reopenIncidentRecord,
setIncidentPostMortem,
deleteIncidentRecord,
};

142
backend/src/data/v1Data.js Normal file
View File

@@ -0,0 +1,142 @@
const { getDatabase } = require('../models/database');
async function getPageMetaRow() {
const db = getDatabase();
return db.all(`SELECT key, value FROM settings WHERE key IN ('title', 'site_url')`);
}
async function getActiveMaintenanceEndpointIds() {
const db = getDatabase();
const rows = await db.all(
`SELECT endpoint_id FROM maintenance_windows
WHERE start_time <= datetime('now')
AND end_time >= datetime('now')
AND endpoint_id IS NOT NULL`
);
return new Set(rows.map((row) => row.endpoint_id));
}
function buildAllowedIdsClause(allowedIds = null) {
if (!allowedIds || allowedIds.length === 0) {
return { clause: '', args: [] };
}
const placeholders = allowedIds.map(() => '?').join(', ');
return {
clause: `AND e.id IN (${placeholders})`,
args: allowedIds,
};
}
async function listComponentsWithLatestAndUptime(allowedIds = null) {
const db = getDatabase();
const { clause, args } = buildAllowedIdsClause(allowedIds);
return db.all(
`WITH latest AS (
SELECT cr.endpoint_id, cr.status, cr.response_time, cr.checked_at
FROM check_results cr
INNER JOIN (
SELECT endpoint_id, MAX(checked_at) AS max_checked_at
FROM check_results
GROUP BY endpoint_id
) m ON m.endpoint_id = cr.endpoint_id AND m.max_checked_at = cr.checked_at
),
uptime AS (
SELECT
endpoint_id,
COUNT(*) AS total,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS ups
FROM check_results
WHERE checked_at > datetime('now', '-30 days')
GROUP BY endpoint_id
)
SELECT
e.id,
e.name,
e.group_id,
e.updated_at,
l.status AS latest_status,
l.response_time AS latest_response_time,
l.checked_at AS latest_checked_at,
COALESCE(u.total, 0) AS uptime_total,
COALESCE(u.ups, 0) AS uptime_ups
FROM endpoints e
LEFT JOIN latest l ON l.endpoint_id = e.id
LEFT JOIN uptime u ON u.endpoint_id = e.id
WHERE e.active = 1
${clause}
ORDER BY e.created_at ASC`,
args
);
}
async function listV1Incidents(activeOnly = false) {
const db = getDatabase();
const where = activeOnly ? 'WHERE i.resolved_at IS NULL' : '';
const incidents = await db.all(
`SELECT i.*
FROM incidents i
${where}
ORDER BY i.created_at DESC`
);
if (incidents.length === 0) return [];
const ids = incidents.map((i) => i.id);
const placeholders = ids.map(() => '?').join(', ');
const updates = await db.all(
`SELECT * FROM incident_updates
WHERE incident_id IN (${placeholders})
ORDER BY created_at ASC`,
ids
);
const endpoints = await db.all(
`SELECT ie.incident_id, e.id, e.name
FROM incident_endpoints ie
JOIN endpoints e ON e.id = ie.endpoint_id
WHERE ie.incident_id IN (${placeholders})`,
ids
);
const updatesByIncident = new Map();
const endpointsByIncident = new Map();
for (const row of updates) {
if (!updatesByIncident.has(row.incident_id)) updatesByIncident.set(row.incident_id, []);
updatesByIncident.get(row.incident_id).push(row);
}
for (const row of endpoints) {
if (!endpointsByIncident.has(row.incident_id)) endpointsByIncident.set(row.incident_id, []);
endpointsByIncident.get(row.incident_id).push({ id: row.id, name: row.name });
}
for (const incident of incidents) {
incident.updates = updatesByIncident.get(incident.id) || [];
incident.endpoints = endpointsByIncident.get(incident.id) || [];
}
return incidents;
}
async function listScheduledMaintenances() {
const db = getDatabase();
return db.all(
`SELECT mw.id, mw.title, mw.description, mw.start_time, mw.end_time, mw.created_at, mw.updated_at,
mw.endpoint_id, e.name AS endpoint_name
FROM maintenance_windows mw
LEFT JOIN endpoints e ON e.id = mw.endpoint_id
WHERE mw.end_time >= datetime('now')
ORDER BY mw.start_time ASC`
);
}
module.exports = {
getPageMetaRow,
getActiveMaintenanceEndpointIds,
listComponentsWithLatestAndUptime,
listV1Incidents,
listScheduledMaintenances,
};

88
backend/src/db/index.js Normal file
View File

@@ -0,0 +1,88 @@
const sqlite3 = require('sqlite3').verbose();
const { open } = require('sqlite');
const path = require('path');
const fs = require('fs');
const { initializeSchema, runMigrations } = require('./schema');
require('dotenv').config();
let db = null;
async function isSchemaPresent(database) {
const row = await database.get(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'settings'`
);
return !!row;
}
async function initializeDatabase() {
if (db) return db;
const dbPath = process.env.DATABASE_PATH
? path.resolve(process.env.DATABASE_PATH)
: path.join(__dirname, '../../data/status.db');
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = await open({
filename: dbPath,
driver: sqlite3.Database,
});
await db.exec('PRAGMA foreign_keys = ON');
const schemaPresent = await isSchemaPresent(db);
if (!schemaPresent) {
await initializeSchema(db);
console.log('Schema initialized (first setup)');
}
await runMigrations(db);
await db.run('DELETE FROM token_blocklist WHERE expires_at < datetime("now")');
console.log('Database initialized');
return db;
}
function getDatabase() {
if (!db) throw new Error('Database not initialized');
return db;
}
async function isSetupComplete() {
try {
const database = getDatabase();
const admin = await database.get('SELECT * FROM users WHERE role = ?', ['admin']);
return !!admin;
} catch (_) {
return false;
}
}
async function runInTransaction(work) {
const database = getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const result = await work(database);
await database.exec('COMMIT');
return result;
} catch (error) {
try {
await database.exec('ROLLBACK');
} catch (_) {
// Ignore rollback errors and rethrow original error
}
throw error;
}
}
module.exports = {
initializeDatabase,
getDatabase,
isSetupComplete,
runInTransaction,
};

310
backend/src/db/schema.js Normal file
View File

@@ -0,0 +1,310 @@
async function initializeSchema(db) {
await db.exec(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS endpoint_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT DEFAULT 'http',
interval INTEGER DEFAULT 300,
active BOOLEAN DEFAULT 1,
timeout INTEGER DEFAULT 10,
group_id INTEGER,
sla_uptime REAL DEFAULT 99.9,
ping_enabled BOOLEAN DEFAULT 0,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(group_id) REFERENCES endpoint_groups(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id INTEGER NOT NULL,
status TEXT,
response_time INTEGER,
error_message TEXT,
ping_response_time INTEGER DEFAULT NULL,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
severity TEXT DEFAULT 'degraded',
status TEXT DEFAULT 'investigating',
source TEXT DEFAULT 'manual',
auto_created BOOLEAN DEFAULT 0,
source_endpoint_id INTEGER,
admin_managed INTEGER DEFAULT 0,
post_mortem TEXT DEFAULT NULL,
start_time DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(source_endpoint_id) REFERENCES endpoints(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS incident_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL,
message TEXT NOT NULL,
status_label TEXT,
created_by TEXT DEFAULT 'system',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(incident_id) REFERENCES incidents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS maintenance_windows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
endpoint_id INTEGER,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS incident_endpoints (
incident_id INTEGER NOT NULL,
endpoint_id INTEGER NOT NULL,
PRIMARY KEY(incident_id, endpoint_id),
FOREIGN KEY(incident_id) REFERENCES incidents(id) ON DELETE CASCADE,
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT,
role TEXT DEFAULT 'viewer',
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS email_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint_id INTEGER,
category_id INTEGER,
scope_type TEXT DEFAULT 'all',
notify_on_down BOOLEAN DEFAULT 1,
notify_on_recovery BOOLEAN DEFAULT 1,
notify_on_degraded BOOLEAN DEFAULT 0,
notify_on_incident BOOLEAN DEFAULT 1,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE,
FOREIGN KEY(category_id) REFERENCES endpoint_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_extra_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notification_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_key TEXT NOT NULL,
recipient_email TEXT NOT NULL,
recipient_name TEXT,
user_id INTEGER,
endpoint_id INTEGER,
incident_id INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
attempt_count INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 5,
next_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,
error_reason TEXT,
payload_json TEXT,
sent_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_key, recipient_email),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE SET NULL,
FOREIGN KEY(incident_id) REFERENCES incidents(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS endpoint_alert_state (
endpoint_id INTEGER PRIMARY KEY,
last_status TEXT,
consecutive_failures INTEGER DEFAULT 0,
outage_started_at DATETIME,
last_alert_sent_at DATETIME,
last_recovery_sent_at DATETIME,
last_reminder_sent_at DATETIME,
last_transition_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sla_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id INTEGER NOT NULL,
month DATE NOT NULL,
uptime_percentage REAL,
total_checks INTEGER,
successful_checks INTEGER,
downtime_minutes INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(endpoint_id, month),
FOREIGN KEY(endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS token_blocklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT UNIQUE NOT NULL,
key_prefix TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'global',
endpoint_ids TEXT DEFAULT NULL,
created_by INTEGER NOT NULL,
last_used_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_check_results_endpoint ON check_results(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_check_results_checked_at ON check_results(checked_at);
CREATE INDEX IF NOT EXISTS idx_incident_endpoints_endpoint ON incident_endpoints(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id);
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_endpoint ON maintenance_windows(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_times ON maintenance_windows(start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_sla_tracking_endpoint ON sla_tracking(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_token_blocklist_token ON token_blocklist(token);
CREATE INDEX IF NOT EXISTS idx_token_blocklist_expires ON token_blocklist(expires_at);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(active);
CREATE INDEX IF NOT EXISTS idx_email_notifications_user ON email_notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_email_notifications_scope ON email_notifications(scope_type);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_event ON notification_deliveries(event_type, created_at);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_endpoint ON notification_deliveries(endpoint_id, created_at);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_incident ON notification_deliveries(incident_id, created_at);
`);
}
async function getColumns(db, tableName) {
return db.all(`PRAGMA table_info(${tableName})`);
}
async function ensureColumn(db, tableName, columnName, definition) {
const columns = await getColumns(db, tableName);
if (columns.some((column) => column.name === columnName)) return;
await db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
}
async function ensureSetting(db, key, value) {
await db.run('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [key, value]);
}
async function runMigrations(db) {
await ensureSetting(db, 'publicUrl', process.env.FRONTEND_URL || 'http://localhost:3000');
await db.exec(`
CREATE TABLE IF NOT EXISTS notification_extra_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notification_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_key TEXT NOT NULL,
recipient_email TEXT NOT NULL,
recipient_name TEXT,
user_id INTEGER,
endpoint_id INTEGER,
incident_id INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
attempt_count INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 5,
next_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,
error_reason TEXT,
payload_json TEXT,
sent_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_key, recipient_email)
);
CREATE TABLE IF NOT EXISTS endpoint_alert_state (
endpoint_id INTEGER PRIMARY KEY,
last_status TEXT,
consecutive_failures INTEGER DEFAULT 0,
outage_started_at DATETIME,
last_alert_sent_at DATETIME,
last_recovery_sent_at DATETIME,
last_reminder_sent_at DATETIME,
last_transition_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
await ensureColumn(db, 'email_notifications', 'category_id', 'INTEGER');
await ensureColumn(db, 'email_notifications', 'scope_type', "TEXT DEFAULT 'all'");
await ensureColumn(db, 'email_notifications', 'notify_on_incident', 'BOOLEAN DEFAULT 1');
await ensureColumn(db, 'email_notifications', 'active', 'BOOLEAN DEFAULT 1');
await ensureColumn(db, 'email_notifications', 'updated_at', 'DATETIME DEFAULT CURRENT_TIMESTAMP');
await db.exec(`
CREATE INDEX IF NOT EXISTS idx_email_notifications_user ON email_notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_email_notifications_scope ON email_notifications(scope_type);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_event ON notification_deliveries(event_type, created_at);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_endpoint ON notification_deliveries(endpoint_id, created_at);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_incident ON notification_deliveries(incident_id, created_at);
`);
await ensureSetting(db, 'smtpTlsMode', 'starttls');
await ensureSetting(db, 'smtpTimeoutMs', '10000');
await ensureSetting(db, 'smtpFromEmail', '');
await ensureSetting(db, 'smtpFromName', '');
await ensureSetting(db, 'notificationsAutoSubscribeAdmins', '1');
await ensureSetting(db, 'notificationFailureThreshold', '2');
await ensureSetting(db, 'notificationCooldownMs', '900000');
await ensureSetting(db, 'notificationReminderMinutes', '60');
}
module.exports = { initializeSchema, runMigrations };

View File

@@ -0,0 +1,74 @@
const bcrypt = require('bcryptjs');
const { getDatabase } = require('../models/database');
/**
* API key authentication middleware.
* Accepts the key via:
* - Authorization: Bearer <key>
* - X-API-Key: <key>
*
* Sets req.apiKey on success with { id, scope, endpoint_ids }.
* Does NOT block unauthenticated requests call requireApiKey() after
* this if you need to enforce auth. Used on /api/v1 routes to enrich
* responses when a valid key is present.
*/
async function optionalApiKey(req, res, next) {
const rawKey = extractKey(req);
if (!rawKey) return next();
try {
const db = getDatabase();
// Keys are prefixed with "sk_" find candidates by prefix (first 12 chars)
const prefix = rawKey.substring(0, 12);
const candidates = await db.all(
`SELECT * FROM api_keys WHERE key_prefix = ? AND active = 1
AND (expires_at IS NULL OR expires_at > datetime('now'))`,
[prefix]
);
for (const candidate of candidates) {
const match = await bcrypt.compare(rawKey, candidate.key_hash);
if (match) {
// Update last_used_at fire and forget, don't block response
db.run('UPDATE api_keys SET last_used_at = datetime("now") WHERE id = ?', [candidate.id]).catch(() => {});
req.apiKey = {
id: candidate.id,
name: candidate.name,
scope: candidate.scope,
endpoint_ids: candidate.endpoint_ids ? JSON.parse(candidate.endpoint_ids) : null
};
break;
}
}
} catch (err) {
console.error('API key auth error:', err);
}
next();
}
/* Middleware that requires a valid API key.
* Must be used after optionalApiKey. */
function requireApiKey(req, res, next) {
if (!req.apiKey) {
return res.status(401).json({ error: 'Valid API key required' });
}
next();
}
function extractKey(req) {
const xApiKey = req.headers['x-api-key'];
if (xApiKey) return xApiKey;
const auth = req.headers['authorization'];
if (auth && auth.startsWith('Bearer ')) {
const token = auth.slice(7);
// Only treat as API key if it starts with our prefix (not a JWT)
if (token.startsWith('sk_')) return token;
}
return null;
}
module.exports = { optionalApiKey, requireApiKey };

View File

@@ -0,0 +1,293 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const dns = require('dns');
const { promisify } = require('util');
const { getDatabase } = require('../models/database');
const dnsResolve4 = promisify(dns.resolve4);
const dnsResolve6 = promisify(dns.resolve6);
/* SHA-256 hash a token so we never store raw JWTs in the blocklist.
* If the DB is compromised, attackers cannot recover the original tokens. */
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// Verify JWT signature first (fast, no DB hit for invalid tokens)
jwt.verify(token, process.env.JWT_SECRET, async (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Check if token has been revoked (logout) via DB
try {
const revoked = await isTokenRevoked(token);
if (revoked) {
return res.status(403).json({ error: 'Token has been revoked' });
}
} catch (dbErr) {
console.error('Token blocklist check failed:', dbErr);
return res.status(500).json({ error: 'Internal server error' });
}
req.user = user;
req.token = token;
next();
});
}
// Role-based authorization middleware
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
/**
* Revoke a token by storing its SHA-256 hash in the database blocklist.
* The token's own expiry is used so we know when to clean it up.
*/
async function revokeToken(token) {
const db = getDatabase();
const tokenHash = hashToken(token);
// Decode (without verifying again) to get the expiry timestamp
const decoded = jwt.decode(token);
const expiresAt = decoded?.exp
? new Date(decoded.exp * 1000).toISOString()
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // fallback 24h
await db.run(
'INSERT OR IGNORE INTO token_blocklist (token, expires_at) VALUES (?, ?)',
[tokenHash, expiresAt]
);
}
async function isTokenRevoked(token) {
const db = getDatabase();
const tokenHash = hashToken(token);
const row = await db.get(
'SELECT id FROM token_blocklist WHERE token = ? AND expires_at > datetime("now")',
[tokenHash]
);
return !!row;
}
/**
* Remove expired tokens from the blocklist.
* Called periodically to keep the table small.
*/
async function cleanupExpiredTokens() {
try {
const db = getDatabase();
const result = await db.run('DELETE FROM token_blocklist WHERE expires_at < datetime("now")');
if (result.changes > 0) {
console.log(`✓ Cleaned up ${result.changes} expired blocked tokens`);
}
} catch (err) {
console.error('Token cleanup error:', err);
}
}
// Password strength validation
function validatePassword(password) {
const errors = [];
if (!password || password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
if (!/[^A-Za-z0-9]/.test(password)) {
errors.push('Password must contain at least one special character');
}
return errors;
}
// URL validation for endpoints (SSRF prevention)
async function validateEndpointUrl(url, type) {
if (!url || typeof url !== 'string') {
return 'URL is required';
}
if (type === 'http') {
// Must start with http:// or https://
if (!/^https?:\/\//i.test(url)) {
return 'HTTP endpoints must start with http:// or https://';
}
try {
const parsed = new URL(url);
const hostname = parsed.hostname;
// Block dangerous schemes
if (!['http:', 'https:'].includes(parsed.protocol)) {
return 'Only http and https protocols are allowed';
}
// Block internal/private IPs (synchronous pattern check)
if (isPrivateHost(hostname)) {
return 'URLs pointing to private/internal addresses are not allowed';
}
// DNS resolution check (catches DNS-rebinding attacks)
if (await resolvesToPrivateIP(hostname)) {
return 'URL resolves to a private/internal address and is not allowed';
}
} catch {
return 'Invalid URL format';
}
} else if (type === 'tcp') {
// TCP format: host:port
const parts = url.split(':');
if (parts.length !== 2 || !parts[1] || isNaN(parseInt(parts[1]))) {
return 'TCP endpoints must be in host:port format';
}
const host = parts[0];
if (!/^[a-zA-Z0-9._-]+$/.test(host)) {
return 'Invalid hostname for TCP endpoint';
}
if (isPrivateHost(host)) {
return 'Addresses pointing to private/internal hosts are not allowed';
}
if (await resolvesToPrivateIP(host)) {
return 'Address resolves to a private/internal host and is not allowed';
}
} else if (type === 'ping') {
// Ping: only valid hostnames/IPs
if (!/^[a-zA-Z0-9._-]+$/.test(url)) {
return 'Invalid hostname for ping endpoint';
}
if (isPrivateHost(url)) {
return 'Addresses pointing to private/internal hosts are not allowed';
}
if (await resolvesToPrivateIP(url)) {
return 'Address resolves to a private/internal host and is not allowed';
}
}
return null; // valid
}
/**
* Check whether a raw IP string is private/reserved.
*/
function isPrivateIP(ip) {
// Unwrap IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1)
const v4Mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
const normalised = v4Mapped ? v4Mapped[1] : ip;
// Exact matches
if (['localhost', '::1', '::'].includes(normalised.toLowerCase())) return true;
// IPv4 checks
const v4Match = normalised.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4Match) {
const [, a, b, c, d] = v4Match.map(Number);
if (a === 0) return true; // 0.0.0.0/8
if (a === 10) return true; // 10.0.0.0/8
if (a === 127) return true; // 127.0.0.0/8
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local + cloud metadata)
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
return false;
}
// IPv6 private/reserved ranges
const v6Ranges = [
/^fc00:/i, // Unique local
/^fd[0-9a-f]{2}:/i, // Unique local
/^fe80:/i, // Link-local
/^::1$/, // Loopback
/^::$/, // Unspecified
];
if (v6Ranges.some(r => r.test(normalised))) return true;
return false;
}
/**
* Synchronous hostname check catches obvious patterns.
* Handles bracket notation, decimal/octal encoded IPs, localhost variants, etc.
*/
function isPrivateHost(hostname) {
// Strip brackets from IPv6 URLs like [::1]
let host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
// Block localhost variants (including subdomains of localhost)
if (host === 'localhost' || host.endsWith('.localhost')) return true;
// Detect and convert decimal-encoded IPs (e.g. 2130706433 = 127.0.0.1)
if (/^\d+$/.test(host)) {
const num = parseInt(host, 10);
if (num >= 0 && num <= 0xFFFFFFFF) {
const a = (num >>> 24) & 0xFF;
const b = (num >>> 16) & 0xFF;
const c = (num >>> 8) & 0xFF;
const d = num & 0xFF;
host = `${a}.${b}.${c}.${d}`;
}
}
// Detect and convert octal-encoded octets (e.g. 0177.0.0.1 = 127.0.0.1)
if (/^0[0-7]*\./.test(host)) {
const parts = host.split('.');
if (parts.length === 4 && parts.every(p => /^0?[0-7]*$/.test(p) || /^\d+$/.test(p))) {
const decoded = parts.map(p => p.startsWith('0') && p.length > 1 ? parseInt(p, 8) : parseInt(p, 10));
if (decoded.every(n => n >= 0 && n <= 255)) {
host = decoded.join('.');
}
}
}
return isPrivateIP(host);
}
/**
* Async DNS-resolution check resolves a hostname and verifies none of the
* resulting IPs are private. Call this in addition to isPrivateHost() to
* prevent DNS-rebinding attacks where a public domain resolves to 127.0.0.1.
*
* Returns true if ANY resolved address is private.
*/
async function resolvesToPrivateIP(hostname) {
// Skip if it already looks like a raw IP
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) return isPrivateIP(hostname);
if (/^[:\da-f]+$/i.test(hostname)) return isPrivateIP(hostname);
const ips = [];
try { ips.push(...await dnsResolve4(hostname)); } catch (_) {}
try { ips.push(...await dnsResolve6(hostname)); } catch (_) {}
// If DNS resolution fails entirely, block it (fail-closed)
if (ips.length === 0) return true;
return ips.some(ip => isPrivateIP(ip));
}
module.exports = {
authenticateToken,
requireRole,
revokeToken,
isTokenRevoked,
cleanupExpiredTokens,
validatePassword,
validateEndpointUrl,
resolvesToPrivateIP
};

View File

@@ -0,0 +1 @@
module.exports = require('../db');

107
backend/src/routes/api.js Normal file
View File

@@ -0,0 +1,107 @@
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const { authenticateToken, requireRole } = require('../middleware/auth');
const endpointController = require('../controllers/endpointController');
const incidentController = require('../controllers/incidentController');
const userController = require('../controllers/userController');
const categoryController = require('../controllers/categoryController');
const { updateSettings } = require('../controllers/setupController');
const apiKeyController = require('../controllers/apiKeyController');
const notificationController = require('../controllers/notificationController');
// ── Rate limiters ──────────────────────────────────────────────────────────
// Public status page routes: 60 requests per minute per IP
const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
message: { error: 'Too many requests, please try again shortly' },
standardHeaders: true,
legacyHeaders: false
});
// Admin routes: 120 requests per minute per IP
const adminLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
message: { error: 'Too many requests, please slow down' },
standardHeaders: true,
legacyHeaders: false
});
// Public routes
// Get all endpoints (public status page)
router.get('/public/endpoints', publicLimiter, endpointController.getAllEndpoints);
router.get('/public/endpoints/:id', publicLimiter, endpointController.getEndpointById);
router.get('/public/endpoints/:id/uptime', publicLimiter, endpointController.getUptime);
router.get('/public/endpoints/:id/history', publicLimiter, endpointController.getHistory);
router.get('/public/endpoints/:id/response-times', publicLimiter, endpointController.getResponseTimes);
// Incidents (public)
router.get('/public/incidents', publicLimiter, incidentController.getAllIncidents);
router.get('/public/incidents/:id', publicLimiter, incidentController.getIncidentById);
// Maintenance windows (public)
router.get('/public/maintenance', publicLimiter, incidentController.getAllMaintenance);
// Protected routes (require authentication + admin role)
// Endpoints - CRUD
router.post('/admin/endpoints', authenticateToken, requireRole('admin'), adminLimiter, endpointController.createEndpoint);
router.put('/admin/endpoints/reorder', authenticateToken, requireRole('admin'), adminLimiter, endpointController.reorderEndpoints);
router.put('/admin/endpoints/:id', authenticateToken, requireRole('admin'), adminLimiter, endpointController.updateEndpoint);
router.delete('/admin/endpoints/:id', authenticateToken, requireRole('admin'), adminLimiter, endpointController.deleteEndpoint);
// Incidents - CRUD
router.post('/admin/incidents', authenticateToken, requireRole('admin'), adminLimiter, incidentController.createIncident);
router.put('/admin/incidents/:id', authenticateToken, requireRole('admin'), adminLimiter, incidentController.updateIncident);
router.delete('/admin/incidents/:id', authenticateToken, requireRole('admin'), adminLimiter, incidentController.deleteIncident);
router.patch('/admin/incidents/:id/resolve', authenticateToken, requireRole('admin'), adminLimiter, incidentController.resolveIncident);
router.post('/admin/incidents/:id/updates', authenticateToken, requireRole('admin'), adminLimiter, incidentController.addIncidentUpdate);
router.post('/admin/incidents/:id/reopen', authenticateToken, requireRole('admin'), adminLimiter, incidentController.reopenIncident);
router.put('/admin/incidents/:id/post-mortem', authenticateToken, requireRole('admin'), adminLimiter, incidentController.setPostMortem);
// Maintenance windows - CRUD
router.post('/admin/maintenance', authenticateToken, requireRole('admin'), adminLimiter, incidentController.createMaintenance);
router.put('/admin/maintenance/:id', authenticateToken, requireRole('admin'), adminLimiter, incidentController.updateMaintenance);
router.delete('/admin/maintenance/:id', authenticateToken, requireRole('admin'), adminLimiter, incidentController.deleteMaintenance);
// Settings
router.post('/admin/settings', authenticateToken, requireRole('admin'), adminLimiter, updateSettings);
router.post('/admin/settings/test-email', authenticateToken, requireRole('admin'), adminLimiter, notificationController.sendSmtpTestEmail);
// Notification preferences (all authenticated users)
router.get('/notifications/preferences', authenticateToken, adminLimiter, notificationController.getMyNotificationPreferences);
router.put('/notifications/preferences', authenticateToken, adminLimiter, notificationController.updateMyNotificationPreferences);
// Notification admin operations
router.get('/admin/notifications/defaults', authenticateToken, requireRole('admin'), adminLimiter, notificationController.getNotificationDefaultsController);
router.put('/admin/notifications/defaults', authenticateToken, requireRole('admin'), adminLimiter, notificationController.updateNotificationDefaultsController);
router.get('/admin/notifications/health', authenticateToken, requireRole('admin'), adminLimiter, notificationController.getSmtpHealthController);
router.get('/admin/notifications/deliveries', authenticateToken, requireRole('admin'), adminLimiter, notificationController.getDeliveryLogs);
router.get('/admin/notifications/extra-recipients', authenticateToken, requireRole('admin'), adminLimiter, notificationController.listExtraRecipients);
router.post('/admin/notifications/extra-recipients', authenticateToken, requireRole('admin'), adminLimiter, notificationController.createExtraRecipient);
router.delete('/admin/notifications/extra-recipients/:id', authenticateToken, requireRole('admin'), adminLimiter, notificationController.deleteExtraRecipient);
// API Keys
router.get('/admin/api-keys', authenticateToken, requireRole('admin'), adminLimiter, apiKeyController.listApiKeys);
router.post('/admin/api-keys', authenticateToken, requireRole('admin'), adminLimiter, apiKeyController.createApiKey);
router.delete('/admin/api-keys/:id', authenticateToken, requireRole('admin'), adminLimiter, apiKeyController.revokeApiKey);
router.delete('/admin/api-keys/:id/hard', authenticateToken, requireRole('admin'), adminLimiter, apiKeyController.deleteApiKey);
// Users - CRUD (admin only)
router.get('/admin/users', authenticateToken, requireRole('admin'), adminLimiter, userController.getAllUsers);
router.post('/admin/users', authenticateToken, requireRole('admin'), adminLimiter, userController.createUser);
router.delete('/admin/users/:id', authenticateToken, requireRole('admin'), adminLimiter, userController.deleteUser);
// Categories - CRUD
router.get('/admin/categories', authenticateToken, requireRole('admin'), adminLimiter, categoryController.getAllCategories);
router.post('/admin/categories', authenticateToken, requireRole('admin'), adminLimiter, categoryController.createCategory);
router.put('/admin/categories/reorder', authenticateToken, requireRole('admin'), adminLimiter, categoryController.reorderCategories);
router.get('/admin/categories/:id', authenticateToken, requireRole('admin'), adminLimiter, categoryController.getCategoryById);
router.put('/admin/categories/:id', authenticateToken, requireRole('admin'), adminLimiter, categoryController.updateCategory);
router.delete('/admin/categories/:id', authenticateToken, requireRole('admin'), adminLimiter, categoryController.deleteCategory);
module.exports = router;

View File

@@ -0,0 +1,49 @@
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const { authenticateToken, requireRole, revokeToken } = require('../middleware/auth');
const { login } = require('../controllers/authController');
const { getSetupStatus, completeSetup, getSiteSettings, getAdminSettings } = require('../controllers/setupController');
const { getUserProfile, updateUserProfile, changePassword: changeUserPassword } = require('../controllers/profileController');
// Rate limiters
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window
message: { error: 'Too many login attempts, please try again after 15 minutes' },
standardHeaders: true,
legacyHeaders: false
});
const setupLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Too many setup attempts, please try again later' },
standardHeaders: true,
legacyHeaders: false
});
// Public routes
router.post('/login', loginLimiter, login);
router.get('/setup', getSetupStatus);
router.post('/setup', setupLimiter, completeSetup);
router.get('/settings', getSiteSettings);
// Protected routes
router.get('/profile', authenticateToken, getUserProfile);
router.get('/settings/admin', authenticateToken, requireRole('admin'), getAdminSettings);
router.put('/profile', authenticateToken, updateUserProfile);
router.post('/change-password', authenticateToken, changeUserPassword);
// Logout - revoke the current token
router.post('/logout', authenticateToken, async (req, res) => {
try {
await revokeToken(req.token);
res.json({ success: true, message: 'Logged out successfully' });
} catch (err) {
console.error('Logout error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

38
backend/src/routes/v1.js Normal file
View File

@@ -0,0 +1,38 @@
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const { optionalApiKey } = require('../middleware/apiKeyAuth');
const v1 = require('../controllers/v1Controller');
// Public API rate limiter: 60 requests per minute per IP
const v1Limiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
message: { error: 'Too many requests, please try again shortly' },
standardHeaders: true,
legacyHeaders: false
});
// Apply rate limiter and optional API key auth to all v1 routes.
// Routes are public by default; a valid key enriches responses.
router.use(v1Limiter);
router.use(optionalApiKey);
// Full status dump primary integration endpoint
router.get('/status.json', v1.getStatusJson);
// Lightweight summary (page name + overall indicator)
router.get('/summary', v1.getSummary);
// Components (endpoints)
router.get('/components', v1.getComponents);
router.get('/components/:id', v1.getComponentById);
// Incidents
router.get('/incidents', v1.getIncidents);
router.get('/incidents/:id', v1.getIncidentById);
// Scheduled maintenances
router.get('/scheduled-maintenances', v1.getScheduledMaintenances);
module.exports = router;

136
backend/src/server.js Normal file
View File

@@ -0,0 +1,136 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const http = require('http');
const { Server } = require('socket.io');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
if (!process.env.JWT_SECRET) {
process.env.JWT_SECRET = crypto.randomBytes(64).toString('hex');
console.log('Generated random JWT_SECRET (one-time)');
}
if (!process.env.ENCRYPTION_KEY) {
process.env.ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
}
const { initializeDatabase } = require('./models/database');
const { scheduleAllEndpoints, setSocket } = require('./services/monitoringService');
const { initializeNotificationWorker } = require('./services/notificationService');
const { cleanupExpiredTokens } = require('./middleware/auth');
const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/auth');
const v1Routes = require('./routes/v1');
function createApp() {
const app = express();
const trustProxy = process.env.TRUST_PROXY !== 'false';
if (trustProxy) app.set('trust proxy', 1);
app.use(helmet());
const allowedOrigin = process.env.FRONTEND_URL || 'http://localhost:3000';
app.use(cors({
origin: allowedOrigin,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
credentials: true,
}));
app.use(express.json({ limit: '1mb' }));
const frontendBuild = path.join(__dirname, '../../frontend/build');
if (fs.existsSync(frontendBuild)) {
app.use(express.static(frontendBuild));
}
app.use('/api/auth', authRoutes);
app.use('/api/v1', v1Routes);
app.use('/api', apiRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
if (fs.existsSync(frontendBuild)) {
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(frontendBuild, 'index.html'));
});
}
return app;
}
function createRealtimeServer(app) {
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
return { server, io };
}
const app = createApp();
const { server, io } = createRealtimeServer(app);
// Startup
async function start() {
try {
// Initialize database
await initializeDatabase();
// Set up monitoring service
setSocket(io);
initializeNotificationWorker();
await scheduleAllEndpoints();
// Clean up expired blocked tokens every hour
const cleanupTimer = setInterval(cleanupExpiredTokens, 60 * 60 * 1000);
if (typeof cleanupTimer.unref === 'function') {
cleanupTimer.unref();
}
// Start server
const PORT = process.env.PORT || 5000;
await new Promise((resolve) => {
server.listen(PORT, resolve);
});
const resolvedPort = server.address() && typeof server.address() === 'object'
? server.address().port
: PORT;
console.log(`\n✓ Arcane Status running on http://localhost:${resolvedPort}`);
if (require('fs').existsSync(path.join(__dirname, '../../frontend/build'))) {
console.log('✓ Serving frontend from build (production mode)');
} else {
console.log("✓ No frontend build found, run 'npm run build' for production");
console.log("✓ For development, run 'npm run dev' from the project root");
}
console.log(`✓ First time? Visit http://localhost:${resolvedPort} to complete setup\n`);
return { server, io };
} catch (error) {
console.error('Failed to start server:', error);
throw error;
}
}
if (require.main === module) {
start().catch(() => {
process.exit(1);
});
}
module.exports = { app, server, io, start, createApp, createRealtimeServer };

View File

@@ -0,0 +1,291 @@
const { getDatabase } = require('../../models/database');
const { queueIncidentNotification } = require('../notificationService');
let io = null;
const AUTO_INCIDENT_THRESHOLD_MIN = 5;
const AUTO_ESCALATE_THRESHOLD_MIN = 30;
function setIncidentSocket(socketInstance) {
io = socketInstance;
}
function parseSQLiteUTC(str) {
if (!str) return new Date();
return new Date(str.replace(' ', 'T') + 'Z');
}
async function getConsecutiveDownMinutes(endpointId) {
const db = getDatabase();
const lastUp = await db.get(
`SELECT checked_at FROM check_results
WHERE endpoint_id = ? AND status = 'up'
ORDER BY checked_at DESC LIMIT 1`,
[endpointId]
);
const sinceTimestamp = lastUp ? lastUp.checked_at : '1970-01-01';
const firstDownAfterUp = await db.get(
`SELECT checked_at FROM check_results
WHERE endpoint_id = ? AND status = 'down'
AND checked_at > ?
ORDER BY checked_at ASC LIMIT 1`,
[endpointId, sinceTimestamp]
);
if (!firstDownAfterUp) return 0;
const outageStart = parseSQLiteUTC(firstDownAfterUp.checked_at);
return (Date.now() - outageStart.getTime()) / 60000;
}
async function getConsecutiveUpCount(endpointId) {
const db = getDatabase();
const recent = await db.all(
`SELECT status FROM check_results
WHERE endpoint_id = ?
ORDER BY checked_at DESC LIMIT 15`,
[endpointId]
);
let count = 0;
for (const row of recent) {
if (row.status === 'up') count++;
else break;
}
return count;
}
async function isInMaintenanceWindow(endpointId) {
const db = getDatabase();
const now = new Date().toISOString();
const window = await db.get(
`SELECT id FROM maintenance_windows
WHERE (endpoint_id = ? OR endpoint_id IS NULL)
AND start_time <= ?
AND end_time >= ?`,
[endpointId, now, now]
);
return !!window;
}
async function handleAutoIncident(endpoint, status) {
const db = getDatabase();
console.log(`[AutoIncident] ${endpoint.name} -> ${status}`);
const openIncident = await db.get(
`SELECT * FROM incidents
WHERE source_endpoint_id = ?
AND auto_created = 1
AND resolved_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[endpoint.id]
);
if (status === 'up') {
if (!openIncident) return;
if (openIncident.admin_managed) {
console.log(`[AutoIncident] Incident #${openIncident.id} is admin-managed, skipping auto-resolve.`);
return;
}
const consecutiveUps = await getConsecutiveUpCount(endpoint.id);
console.log(`[AutoIncident] ${endpoint.name} up streak: ${consecutiveUps}/10`);
if (consecutiveUps < 10) {
if (openIncident.severity === 'down') {
await db.run(
`UPDATE incidents SET severity = 'degraded', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[openIncident.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'monitoring', 'system')`,
[
openIncident.id,
`**${endpoint.name}** appears to be partially recovering. Severity has been reduced to degraded while we continue monitoring for stability.`
]
);
const updatedIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [openIncident.id]);
if (io) io.emit('incidentUpdated', updatedIncident);
}
if (openIncident.status !== 'monitoring') {
await db.run(
`UPDATE incidents SET status = 'monitoring', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[openIncident.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'monitoring', 'system')`,
[
openIncident.id,
`**${endpoint.name}** is responding again. We are monitoring the service for stability before closing this incident (${consecutiveUps}/10 checks passed).`
]
);
const monitoringIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [openIncident.id]);
console.log(`[AutoIncident] Incident #${openIncident.id} moved to monitoring (${consecutiveUps}/10 ups)`);
if (io) io.emit('incidentUpdated', monitoringIncident);
await queueIncidentNotification('incident_updated', openIncident.id, `${endpoint.name} entered monitoring state.`);
}
return;
}
await db.run(
`UPDATE incidents
SET resolved_at = CURRENT_TIMESTAMP,
status = 'resolved',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[openIncident.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'resolved', 'system')`,
[
openIncident.id,
`The issue with **${endpoint.name}** has been resolved. The service has passed 10 consecutive health checks and is operating normally.`
]
);
const resolvedIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [openIncident.id]);
console.log(`Auto-resolved incident #${openIncident.id} for ${endpoint.name}`);
if (io) io.emit('incidentResolved', resolvedIncident);
await queueIncidentNotification('incident_resolved', openIncident.id, `${endpoint.name} has recovered and the incident is resolved.`);
return;
}
if (await isInMaintenanceWindow(endpoint.id)) {
console.log(`[AutoIncident] ${endpoint.name} is in a maintenance window, skipping.`);
return;
}
if (openIncident && openIncident.status === 'monitoring') {
await db.run(
`UPDATE incidents
SET status = 'investigating',
severity = 'degraded',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[openIncident.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'investigating', 'system')`,
[
openIncident.id,
`**${endpoint.name}** became unavailable again while under stability monitoring. The up-check counter has been reset. Our team continues to investigate.`
]
);
const revertedIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [openIncident.id]);
console.log(`Incident #${openIncident.id} reverted from monitoring to investigating for ${endpoint.name}`);
if (io) io.emit('incidentUpdated', revertedIncident);
await queueIncidentNotification('incident_updated', openIncident.id, `${endpoint.name} became unavailable again.`);
return;
}
const minutesDown = await getConsecutiveDownMinutes(endpoint.id);
console.log(`[AutoIncident] ${endpoint.name} down ~${minutesDown.toFixed(1)}m | openIncident: ${openIncident ? '#' + openIncident.id : 'none'}`);
if (openIncident && minutesDown >= AUTO_ESCALATE_THRESHOLD_MIN && openIncident.severity !== 'down') {
await db.run(
`UPDATE incidents SET severity = 'down', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[openIncident.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'identified', 'system')`,
[
openIncident.id,
`**${endpoint.name}** has been unavailable for over 30 minutes. Severity escalated to critical. Our team is actively investigating.`
]
);
const escalatedIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [openIncident.id]);
console.log(`Auto-escalated incident #${openIncident.id} for ${endpoint.name} to 'down'`);
if (io) io.emit('incidentUpdated', escalatedIncident);
await queueIncidentNotification('incident_updated', openIncident.id, `${endpoint.name} outage severity escalated.`);
return;
}
if (openIncident) return;
const openIncidentByTag = await db.get(
`SELECT i.id FROM incidents i
JOIN incident_endpoints ie ON ie.incident_id = i.id
WHERE ie.endpoint_id = ?
AND i.resolved_at IS NULL
ORDER BY i.created_at DESC LIMIT 1`,
[endpoint.id]
);
if (openIncidentByTag) {
console.log(`[AutoIncident] Open incident #${openIncidentByTag.id} already covers ${endpoint.name} (via tag), skipping auto-create.`);
return;
}
if (minutesDown < AUTO_INCIDENT_THRESHOLD_MIN) {
console.log(`[AutoIncident] ${endpoint.name} not yet at threshold (${minutesDown.toFixed(1)}m < ${AUTO_INCIDENT_THRESHOLD_MIN}m), waiting.`);
return;
}
const REOPEN_COOLDOWN_MIN = 30;
const recentlyResolved = await db.get(
`SELECT * FROM incidents
WHERE source_endpoint_id = ?
AND auto_created = 1
AND resolved_at IS NOT NULL
AND resolved_at >= datetime('now', '-${REOPEN_COOLDOWN_MIN} minutes')
ORDER BY resolved_at DESC LIMIT 1`,
[endpoint.id]
);
if (recentlyResolved) {
await db.run(
`UPDATE incidents
SET resolved_at = NULL,
status = 'investigating',
severity = 'degraded',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[recentlyResolved.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'investigating', 'system')`,
[
recentlyResolved.id,
`This incident has been automatically re-opened. **${endpoint.name}** became unavailable again within ${REOPEN_COOLDOWN_MIN} minutes of the previous resolution.`
]
);
const reopenedIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [recentlyResolved.id]);
console.log(`Re-opened incident #${recentlyResolved.id} for ${endpoint.name} (flap within ${REOPEN_COOLDOWN_MIN}m)`);
if (io) io.emit('incidentCreated', { ...reopenedIncident, endpoints: [endpoint] });
await queueIncidentNotification('incident_updated', recentlyResolved.id, `${endpoint.name} outage re-opened after recent recovery.`);
return;
}
const title = `${endpoint.name} is experiencing issues`;
const description = `Our systems have detected an issue with **${endpoint.name}**. Our team has been notified and updates will be provided shortly.`;
const result = await db.run(
`INSERT INTO incidents
(title, description, severity, status, source, auto_created, source_endpoint_id)
VALUES (?, ?, 'degraded', 'investigating', 'auto', 1, ?)`,
[title, description, endpoint.id]
);
const incidentId = result.lastID;
await db.run(
'INSERT INTO incident_endpoints (incident_id, endpoint_id) VALUES (?, ?)',
[incidentId, endpoint.id]
);
await db.run(
`INSERT INTO incident_updates (incident_id, message, status_label, created_by) VALUES (?, ?, 'investigating', 'system')`,
[incidentId, description]
);
const newIncident = await db.get('SELECT * FROM incidents WHERE id = ?', [incidentId]);
console.log(`Auto-created incident #${incidentId} for ${endpoint.name} (down ${Math.round(minutesDown)}m)`);
if (io) io.emit('incidentCreated', { ...newIncident, endpoints: [endpoint] });
await queueIncidentNotification('incident_created', incidentId, description);
}
module.exports = {
setIncidentSocket,
handleAutoIncident,
};

View File

@@ -0,0 +1,24 @@
const axios = require('axios');
const { checkPing } = require('./pingChecker');
function extractHostname(url) {
return url.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/:\d+$/, '').replace(/\/$/, '');
}
async function checkHTTP(endpoint, timeoutSeconds) {
const startTime = Date.now();
const response = await axios.head(endpoint.url, { timeout: timeoutSeconds * 1000 });
const status = response.status >= 200 && response.status < 400 ? 'up' : 'down';
const responseTime = Date.now() - startTime;
let pingResponseTime = null;
if (endpoint.ping_enabled) {
const hostname = extractHostname(endpoint.url);
pingResponseTime = await checkPing(hostname, timeoutSeconds);
}
return { status, responseTime, pingResponseTime };
}
module.exports = { checkHTTP };

View File

@@ -0,0 +1,30 @@
const { execFile } = require('child_process');
function checkPing(host, timeout) {
return new Promise((resolve, reject) => {
const validHost = /^[a-zA-Z0-9._-]+$/.test(host);
if (!validHost) {
return reject(new Error('Invalid hostname'));
}
const args = process.platform === 'win32'
? ['-n', '1', '-w', String(timeout * 1000), host]
: ['-4', '-c', '1', '-W', String(timeout), host];
execFile('ping', args, (error, stdout, stderr) => {
if (error) {
const detail = (stderr || stdout || error.message || '').trim();
reject(new Error(detail || error.message));
} else {
let avg = null;
const linuxMatch = stdout.match(/rtt[^=]+=\s*[\d.]+\/([\d.]+)\//i);
const winMatch = stdout.match(/Average\s*=\s*([\d.]+)ms/i);
if (linuxMatch) avg = Math.round(parseFloat(linuxMatch[1]));
else if (winMatch) avg = Math.round(parseFloat(winMatch[1]));
resolve(avg);
}
});
});
}
module.exports = { checkPing };

View File

@@ -0,0 +1,21 @@
const net = require('net');
function checkTCP(host, port, timeout) {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
socket.setTimeout(timeout * 1000);
socket.on('error', reject);
socket.on('timeout', () => {
socket.destroy();
reject(new Error('TCP connection timeout'));
});
socket.connect(port, host, () => {
socket.destroy();
resolve();
});
});
}
module.exports = { checkTCP };

View File

@@ -0,0 +1,138 @@
const cron = require('node-cron');
const { getDatabase } = require('../models/database');
const { checkHTTP } = require('./monitoring/checkers/httpChecker');
const { checkTCP } = require('./monitoring/checkers/tcpChecker');
const { checkPing } = require('./monitoring/checkers/pingChecker');
const { handleAutoIncident, setIncidentSocket } = require('./incident/autoIncidentService');
const { processEndpointTransition } = require('./notificationService');
let io = null;
const scheduledTasks = new Map();
function setSocket(socketInstance) {
io = socketInstance;
setIncidentSocket(socketInstance);
}
async function performCheck(endpoint) {
const startTime = Date.now();
let status = 'down';
let responseTime = 0;
let errorMessage = null;
let pingResponseTime = null;
const db = getDatabase();
const stillExists = await db.get('SELECT id FROM endpoints WHERE id = ?', [endpoint.id]);
if (!stillExists) {
console.warn(`[Monitor] Skipping check for deleted endpoint "${endpoint.name}" (id=${endpoint.id}) - stopping task`);
stopScheduling(endpoint.id);
return { status: 'unknown', responseTime: 0, errorMessage: 'endpoint deleted' };
}
console.log(`[Monitor] Checking "${endpoint.name}" (id=${endpoint.id}, type=${endpoint.type}, url=${endpoint.url})`);
try {
if (endpoint.type === 'http') {
const httpResult = await checkHTTP(endpoint, endpoint.timeout);
status = httpResult.status;
responseTime = httpResult.responseTime;
pingResponseTime = httpResult.pingResponseTime;
} else if (endpoint.type === 'tcp') {
const [host, port] = endpoint.url.split(':');
await checkTCP(host, parseInt(port, 10), endpoint.timeout);
status = 'up';
responseTime = Date.now() - startTime;
} else if (endpoint.type === 'ping') {
const rtt = await checkPing(endpoint.url, endpoint.timeout);
status = 'up';
responseTime = rtt !== null ? rtt : (Date.now() - startTime);
}
} catch (error) {
status = 'down';
responseTime = Date.now() - startTime;
errorMessage = error.message;
console.warn(`[Monitor] ${endpoint.type} check FAILED for "${endpoint.name}" (${endpoint.url}): ${error.message}`);
}
console.log(`[Monitor] ${endpoint.type} result for "${endpoint.name}": ${status} (${responseTime}ms)${errorMessage ? ' | ' + errorMessage : ''}`);
await db.run(
'INSERT INTO check_results (endpoint_id, status, response_time, error_message, ping_response_time) VALUES (?, ?, ?, ?, ?)',
[endpoint.id, status, responseTime, errorMessage, pingResponseTime]
);
if (io) {
io.emit('checkResult', {
endpoint_id: endpoint.id,
status,
responseTime,
checked_at: new Date(),
});
}
try {
await handleAutoIncident(endpoint, status);
} catch (err) {
console.error(`[AutoIncident] Error processing ${endpoint.name}:`, err.message, err.stack);
}
try {
await processEndpointTransition(endpoint, status, responseTime, new Date().toISOString());
} catch (err) {
console.error(`[Notification] Error processing endpoint notification for ${endpoint.name}:`, err.message);
}
return { status, responseTime, errorMessage };
}
async function scheduleEndpoint(endpoint) {
if (scheduledTasks.has(endpoint.id)) {
const task = scheduledTasks.get(endpoint.id);
task.stop();
}
if (!endpoint.active) return;
const minInterval = 30;
const interval = Math.max(minInterval, parseInt(endpoint.interval, 10) || 300);
const cronExpression = `*/${interval} * * * * *`;
const task = cron.schedule(
cronExpression,
() => {
performCheck(endpoint).catch((err) => console.error(`Check failed for ${endpoint.name}:`, err));
},
{ runOnInit: true }
);
scheduledTasks.set(endpoint.id, task);
console.log(`Scheduled ${endpoint.name} every ${interval}s`);
}
async function scheduleAllEndpoints() {
const db = getDatabase();
const endpoints = await db.all('SELECT * FROM endpoints WHERE active = 1');
for (const endpoint of endpoints) {
await scheduleEndpoint(endpoint);
}
console.log(`Scheduled ${endpoints.length} endpoints`);
}
function stopScheduling(endpointId) {
if (scheduledTasks.has(endpointId)) {
const task = scheduledTasks.get(endpointId);
task.stop();
scheduledTasks.delete(endpointId);
console.log(`Stopped scheduling endpoint ${endpointId}`);
}
}
module.exports = {
setSocket,
performCheck,
scheduleEndpoint,
scheduleAllEndpoints,
stopScheduling,
};

View File

@@ -0,0 +1,516 @@
const { getDatabase, runInTransaction } = require('../models/database');
const { getSetting, setSettings } = require('./settingsService');
const { sendMail, registerSmtpFailure } = require('./smtpService');
const { renderTemplate } = require('./notificationTemplates');
const EVENT_TYPES = [
'endpoint_down',
'endpoint_degraded',
'endpoint_recovered',
'incident_created',
'incident_updated',
'incident_resolved',
];
let workerTimer = null;
let workerRunning = false;
function toIso(value = Date.now()) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString();
}
function mapEventToPreferenceField(eventType) {
if (eventType === 'endpoint_down') return 'notify_on_down';
if (eventType === 'endpoint_degraded') return 'notify_on_degraded';
if (eventType === 'endpoint_recovered') return 'notify_on_recovery';
return 'notify_on_incident';
}
async function ensureUserNotificationDefaults(userId, role) {
const db = getDatabase();
const existing = await db.get(
"SELECT id FROM email_notifications WHERE user_id = ? AND scope_type = 'all' AND endpoint_id IS NULL AND category_id IS NULL",
[userId]
);
if (existing) return;
const autoSubscribeAdmins = String(await getSetting('notificationsAutoSubscribeAdmins', '1')) === '1';
const enabled = role === 'admin' ? (autoSubscribeAdmins ? 1 : 0) : 0;
await db.run(
`INSERT INTO email_notifications
(user_id, endpoint_id, category_id, scope_type, notify_on_down, notify_on_recovery, notify_on_degraded, notify_on_incident, active)
VALUES (?, NULL, NULL, 'all', ?, ?, ?, ?, 1)`,
[userId, enabled, enabled, enabled, enabled]
);
}
async function isEndpointInMaintenance(endpointId) {
if (!endpointId) return false;
const db = getDatabase();
const now = new Date().toISOString();
const row = await db.get(
`SELECT id FROM maintenance_windows
WHERE (endpoint_id = ? OR endpoint_id IS NULL)
AND start_time <= ?
AND end_time >= ?
LIMIT 1`,
[endpointId, now, now]
);
return !!row;
}
async function getEndpointAlertState(endpointId) {
const db = getDatabase();
const state = await db.get('SELECT * FROM endpoint_alert_state WHERE endpoint_id = ?', [endpointId]);
if (state) return state;
await db.run(
`INSERT INTO endpoint_alert_state (endpoint_id, last_status, consecutive_failures, updated_at)
VALUES (?, NULL, 0, CURRENT_TIMESTAMP)`,
[endpointId]
);
return db.get('SELECT * FROM endpoint_alert_state WHERE endpoint_id = ?', [endpointId]);
}
function shouldEmitByCooldown(lastAlertAt, cooldownMs) {
if (!lastAlertAt) return true;
return Date.now() - new Date(lastAlertAt).getTime() >= cooldownMs;
}
async function processEndpointTransition(endpoint, status, responseTime, checkedAt) {
const db = getDatabase();
const state = await getEndpointAlertState(endpoint.id);
const threshold = Number(await getSetting('notificationFailureThreshold', '2')) || 2;
const cooldownMs = Number(await getSetting('notificationCooldownMs', '900000')) || 900000;
const reminderMinutes = Number(await getSetting('notificationReminderMinutes', '60')) || 60;
const inMaintenance = await isEndpointInMaintenance(endpoint.id);
const timestamp = checkedAt || new Date().toISOString();
const basePayload = {
endpoint: {
id: endpoint.id,
name: endpoint.name,
status,
responseTime,
url: endpoint.url,
},
timestamp,
maintenance: inMaintenance,
};
const lastStatus = state.last_status || null;
let nextFailures = status === 'down' ? Number(state.consecutive_failures || 0) + 1 : 0;
if (status === 'up') {
const wasOutage = lastStatus === 'down' || lastStatus === 'degraded';
if (wasOutage && state.outage_started_at) {
await queueNotificationEvent('endpoint_recovered', {
endpointId: endpoint.id,
eventKey: `endpoint_recovered:${endpoint.id}:${state.outage_started_at}`,
payload: {
...basePayload,
message: `${endpoint.name} is responding normally again.`,
},
});
}
await db.run(
`UPDATE endpoint_alert_state
SET last_status = 'up',
consecutive_failures = 0,
outage_started_at = NULL,
last_recovery_sent_at = CURRENT_TIMESTAMP,
last_transition_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE endpoint_id = ?`,
[endpoint.id]
);
return;
}
if (status === 'down') {
const outageStart = state.outage_started_at || toIso();
const transitionToDown = lastStatus !== 'down' && nextFailures >= threshold;
const cooldownAllowed = shouldEmitByCooldown(state.last_alert_sent_at, cooldownMs);
if (transitionToDown && cooldownAllowed) {
if (!inMaintenance) {
await queueNotificationEvent('endpoint_down', {
endpointId: endpoint.id,
eventKey: `endpoint_down:${endpoint.id}:${outageStart}`,
payload: {
...basePayload,
message: `${endpoint.name} has failed ${nextFailures} consecutive health checks.`,
},
});
}
await db.run(
`UPDATE endpoint_alert_state
SET last_status = 'down',
consecutive_failures = ?,
outage_started_at = ?,
last_alert_sent_at = CURRENT_TIMESTAMP,
last_transition_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE endpoint_id = ?`,
[nextFailures, outageStart, endpoint.id]
);
return;
}
const reminderMs = reminderMinutes > 0 ? reminderMinutes * 60 * 1000 : 0;
const lastReminderAt = state.last_reminder_sent_at ? new Date(state.last_reminder_sent_at).getTime() : 0;
if (!inMaintenance && reminderMs > 0 && state.outage_started_at && Date.now() - lastReminderAt >= reminderMs && lastStatus === 'down') {
await queueNotificationEvent('endpoint_down', {
endpointId: endpoint.id,
eventKey: `endpoint_down:reminder:${endpoint.id}:${Math.floor(Date.now() / reminderMs)}`,
payload: {
...basePayload,
reminder: true,
message: `${endpoint.name} remains unavailable.`,
},
});
await db.run(
`UPDATE endpoint_alert_state
SET consecutive_failures = ?,
last_reminder_sent_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE endpoint_id = ?`,
[nextFailures, endpoint.id]
);
return;
}
await db.run(
`UPDATE endpoint_alert_state
SET consecutive_failures = ?,
outage_started_at = COALESCE(outage_started_at, ?),
updated_at = CURRENT_TIMESTAMP
WHERE endpoint_id = ?`,
[nextFailures, outageStart, endpoint.id]
);
return;
}
if (status === 'degraded') {
const transitionToDegraded = lastStatus !== 'degraded';
if (transitionToDegraded && !inMaintenance && shouldEmitByCooldown(state.last_alert_sent_at, cooldownMs)) {
await queueNotificationEvent('endpoint_degraded', {
endpointId: endpoint.id,
eventKey: `endpoint_degraded:${endpoint.id}:${Math.floor(Date.now() / 60000)}`,
payload: {
...basePayload,
message: `${endpoint.name} is experiencing degraded performance.`,
},
});
}
await db.run(
`UPDATE endpoint_alert_state
SET last_status = 'degraded',
consecutive_failures = 0,
outage_started_at = COALESCE(outage_started_at, ?),
last_alert_sent_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE last_alert_sent_at END,
last_transition_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE last_transition_at END,
updated_at = CURRENT_TIMESTAMP
WHERE endpoint_id = ?`,
[toIso(), transitionToDegraded ? 1 : 0, transitionToDegraded ? 1 : 0, endpoint.id]
);
}
}
async function queueNotificationEvent(eventType, { endpointId = null, incidentId = null, eventKey, payload = {} }) {
if (!EVENT_TYPES.includes(eventType)) return;
const recipients = await resolveRecipients({ eventType, endpointId, incidentId });
if (recipients.length === 0) return;
const db = getDatabase();
const serialized = JSON.stringify(payload || {});
await runInTransaction(async (database) => {
for (const recipient of recipients) {
await database.run(
`INSERT OR IGNORE INTO notification_deliveries
(event_type, event_key, recipient_email, recipient_name, user_id, endpoint_id, incident_id, status, attempt_count, max_attempts, next_attempt_at, payload_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, 5, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
eventType,
eventKey,
recipient.email,
recipient.name || null,
recipient.userId || null,
endpointId,
incidentId,
serialized,
]
);
}
});
triggerWorkerSoon();
}
async function resolveRecipients({ eventType, endpointId, incidentId }) {
const db = getDatabase();
const preferenceField = mapEventToPreferenceField(eventType);
const users = await db.all('SELECT id, email, name, role, active FROM users WHERE active = 1');
const recipients = [];
for (const user of users) {
await ensureUserNotificationDefaults(user.id, user.role);
const allScope = await db.get(
`SELECT * FROM email_notifications
WHERE user_id = ? AND scope_type = 'all' AND endpoint_id IS NULL AND category_id IS NULL
LIMIT 1`,
[user.id]
);
if (!allScope || Number(allScope.active) !== 1 || Number(allScope[preferenceField]) !== 1) {
continue;
}
const selectedRows = await db.all(
`SELECT scope_type, endpoint_id, category_id
FROM email_notifications
WHERE user_id = ? AND scope_type IN ('endpoint', 'category') AND active = 1`,
[user.id]
);
const hasScopedRules = selectedRows.length > 0;
if (hasScopedRules) {
const endpointIds = new Set();
const categoryIds = new Set();
selectedRows.forEach((row) => {
if (row.scope_type === 'endpoint' && row.endpoint_id) endpointIds.add(Number(row.endpoint_id));
if (row.scope_type === 'category' && row.category_id) categoryIds.add(Number(row.category_id));
});
let match = false;
if (endpointId && endpointIds.has(Number(endpointId))) {
match = true;
}
if (!match && endpointId && categoryIds.size > 0) {
const endpoint = await db.get('SELECT group_id FROM endpoints WHERE id = ?', [endpointId]);
if (endpoint?.group_id && categoryIds.has(Number(endpoint.group_id))) {
match = true;
}
}
if (!match && incidentId) {
const incidentEndpoints = await db.all('SELECT endpoint_id FROM incident_endpoints WHERE incident_id = ?', [incidentId]);
for (const row of incidentEndpoints) {
if (endpointIds.has(Number(row.endpoint_id))) {
match = true;
break;
}
if (categoryIds.size > 0) {
const endpoint = await db.get('SELECT group_id FROM endpoints WHERE id = ?', [row.endpoint_id]);
if (endpoint?.group_id && categoryIds.has(Number(endpoint.group_id))) {
match = true;
break;
}
}
}
}
if (!match) continue;
}
recipients.push({ userId: user.id, email: user.email, name: user.name || null });
}
const extras = await db.all('SELECT id, email, name FROM notification_extra_recipients WHERE active = 1');
for (const extra of extras) {
recipients.push({ userId: null, email: extra.email, name: extra.name || null });
}
return recipients;
}
async function attachEventContext(delivery) {
const db = getDatabase();
const payload = delivery.payload_json ? JSON.parse(delivery.payload_json) : {};
if (delivery.endpoint_id) {
const endpoint = await db.get('SELECT id, name, url FROM endpoints WHERE id = ?', [delivery.endpoint_id]);
if (endpoint) {
payload.endpoint = {
...(payload.endpoint || {}),
id: endpoint.id,
name: endpoint.name,
url: endpoint.url,
};
}
}
if (delivery.incident_id) {
const incident = await db.get('SELECT id, title, status, updated_at FROM incidents WHERE id = ?', [delivery.incident_id]);
if (incident) {
payload.incident = incident;
}
}
const settings = await getSettingsMap();
const publicUrl = String(settings.publicUrl || process.env.PUBLIC_STATUS_PAGE_URL || process.env.FRONTEND_URL || 'http://localhost:3000');
payload.statusPageUrl = publicUrl;
payload.timestamp = payload.timestamp || new Date().toISOString();
return payload;
}
function getBackoffMs(attemptCount) {
const base = 30 * 1000;
return Math.min(base * (2 ** Math.max(0, attemptCount - 1)), 30 * 60 * 1000);
}
async function processNotificationDelivery(delivery) {
const db = getDatabase();
const payload = await attachEventContext(delivery);
const template = renderTemplate(delivery.event_type, payload);
try {
await sendMail({
to: delivery.recipient_email,
subject: template.subject,
text: template.text,
html: template.html,
});
await db.run(
`UPDATE notification_deliveries
SET status = 'sent',
attempt_count = attempt_count + 1,
sent_at = CURRENT_TIMESTAMP,
error_reason = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[delivery.id]
);
} catch (error) {
const nextAttempt = Number(delivery.attempt_count || 0) + 1;
const maxAttempts = Number(delivery.max_attempts || 5);
const shouldFail = nextAttempt >= maxAttempts;
const retryDelay = getBackoffMs(nextAttempt);
await db.run(
`UPDATE notification_deliveries
SET status = ?,
attempt_count = ?,
error_reason = ?,
next_attempt_at = CASE WHEN ? THEN next_attempt_at ELSE datetime('now', '+' || ? || ' seconds') END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
shouldFail ? 'failed' : 'queued',
nextAttempt,
(error.message || 'SMTP send failed').slice(0, 1000),
shouldFail ? 1 : 0,
Math.ceil(retryDelay / 1000),
delivery.id,
]
);
await registerSmtpFailure(error);
}
}
async function processDueDeliveries(limit = 20) {
if (workerRunning) return;
workerRunning = true;
try {
const db = getDatabase();
const deliveries = await db.all(
`SELECT * FROM notification_deliveries
WHERE status = 'queued'
AND datetime(next_attempt_at) <= datetime('now')
ORDER BY created_at ASC
LIMIT ?`,
[limit]
);
for (const delivery of deliveries) {
await processNotificationDelivery(delivery);
}
} finally {
workerRunning = false;
}
}
function triggerWorkerSoon() {
setTimeout(() => {
processDueDeliveries().catch((error) => {
console.error('Notification worker error:', error);
});
}, 50);
}
function initializeNotificationWorker() {
if (workerTimer) return;
workerTimer = setInterval(() => {
processDueDeliveries().catch((error) => {
console.error('Notification worker error:', error);
});
}, 5000);
if (typeof workerTimer.unref === 'function') {
workerTimer.unref();
}
}
async function queueIncidentNotification(eventType, incidentId, message = '') {
const suffix = Date.now();
await queueNotificationEvent(eventType, {
incidentId,
eventKey: `${eventType}:incident:${incidentId}:${suffix}`,
payload: {
message,
timestamp: new Date().toISOString(),
},
});
}
async function getNotificationHealth() {
const failureStreak = Number(await getSetting('smtpFailureStreak', '0')) || 0;
return {
lastSuccessfulSendAt: await getSetting('smtpLastSuccessAt', ''),
lastError: await getSetting('smtpLastError', ''),
lastErrorAt: await getSetting('smtpLastErrorAt', ''),
failureStreak,
healthy: failureStreak === 0,
};
}
async function setNotificationDefaults({ autoSubscribeAdmins, failureThreshold, cooldownMs, reminderMinutes }) {
await setSettings({
notificationsAutoSubscribeAdmins: autoSubscribeAdmins ? '1' : '0',
notificationFailureThreshold: String(Math.max(1, Number(failureThreshold) || 2)),
notificationCooldownMs: String(Math.max(1000, Number(cooldownMs) || 900000)),
notificationReminderMinutes: String(Math.max(0, Number(reminderMinutes) || 0)),
});
}
async function getNotificationDefaults() {
return {
autoSubscribeAdmins: String(await getSetting('notificationsAutoSubscribeAdmins', '1')) === '1',
failureThreshold: Number(await getSetting('notificationFailureThreshold', '2')) || 2,
cooldownMs: Number(await getSetting('notificationCooldownMs', '900000')) || 900000,
reminderMinutes: Number(await getSetting('notificationReminderMinutes', '60')) || 60,
};
}
module.exports = {
EVENT_TYPES,
initializeNotificationWorker,
processEndpointTransition,
queueNotificationEvent,
queueIncidentNotification,
ensureUserNotificationDefaults,
getNotificationHealth,
getNotificationDefaults,
setNotificationDefaults,
};

View File

@@ -0,0 +1,104 @@
function formatTimestamp(value) {
if (!value) return 'Unknown';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return 'Unknown';
return date.toISOString().replace('T', ' ').replace('Z', ' UTC');
}
function buildSubject(eventType, payload) {
const endpointName = payload.endpoint?.name || 'Endpoint';
const incidentTitle = payload.incident?.title || 'Incident';
switch (eventType) {
case 'endpoint_down':
return `[Status] ${endpointName} is down`;
case 'endpoint_degraded':
return `[Status] ${endpointName} is degraded`;
case 'endpoint_recovered':
return `[Status] ${endpointName} recovered`;
case 'incident_created':
return `[Incident] ${incidentTitle}`;
case 'incident_updated':
return `[Incident Update] ${incidentTitle}`;
case 'incident_resolved':
return `[Resolved] ${incidentTitle}`;
default:
return '[Status] Notification';
}
}
function buildLines(eventType, payload) {
const lines = [];
const pageUrl = payload.statusPageUrl || '';
if (payload.endpoint) {
lines.push(`Endpoint: ${payload.endpoint.name}`);
if (payload.endpoint.status) lines.push(`Status: ${payload.endpoint.status}`);
if (typeof payload.endpoint.responseTime === 'number') {
lines.push(`Response time: ${payload.endpoint.responseTime} ms`);
}
}
if (payload.incident) {
lines.push(`Incident: ${payload.incident.title}`);
if (payload.incident.status) lines.push(`Incident status: ${payload.incident.status}`);
}
if (payload.message) {
lines.push(`Message: ${payload.message}`);
}
if (payload.timestamp) {
lines.push(`Timestamp: ${formatTimestamp(payload.timestamp)}`);
}
if (payload.maintenance === true) {
lines.push('Maintenance: This event happened during a maintenance window.');
}
if (pageUrl) {
lines.push(`Status page: ${pageUrl}`);
}
if (eventType === 'endpoint_down' && payload.reminder === true) {
lines.push('Reminder: The outage is still ongoing.');
}
return lines;
}
function renderTemplate(eventType, payload) {
const subject = buildSubject(eventType, payload);
const lines = buildLines(eventType, payload);
const text = `${subject}\n\n${lines.join('\n')}`;
const htmlRows = lines
.map((line) => `<tr><td style="padding:6px 0;font-size:14px;color:#334155;">${escapeHtml(line)}</td></tr>`)
.join('');
const html = `
<div style="font-family:Verdana,Segoe UI,sans-serif;background:#f8fafc;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:0 auto;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:20px;">
<tr>
<td style="font-size:20px;font-weight:700;color:#0f172a;padding-bottom:12px;">${escapeHtml(subject)}</td>
</tr>
${htmlRows}
</table>
</div>
`;
return { subject, text, html };
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
module.exports = {
renderTemplate,
};

View File

@@ -0,0 +1,165 @@
const { getDatabase } = require('../models/database');
const { encrypt, decrypt } = require('../utils/crypto');
const TLS_MODES = ['none', 'starttls', 'tls'];
function parseMaybeJson(value) {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
async function getSettingsMap() {
const db = getDatabase();
const rows = await db.all('SELECT key, value FROM settings');
return rows.reduce((acc, row) => {
acc[row.key] = parseMaybeJson(row.value);
return acc;
}, {});
}
async function getSetting(key, fallback = null) {
const db = getDatabase();
const row = await db.get('SELECT value FROM settings WHERE key = ?', [key]);
if (!row) return fallback;
return parseMaybeJson(row.value);
}
async function setSettings(updates) {
const db = getDatabase();
for (const [key, value] of Object.entries(updates)) {
await db.run(
'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)',
[key, typeof value === 'string' ? value : JSON.stringify(value)]
);
}
}
function coerceNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
async function getSmtpConfig({ includePassword = false } = {}) {
const settings = await getSettingsMap();
const encryptedPassword = String(settings.smtpPassword || '');
const smtpConfig = {
smtpHost: String(settings.smtpHost || ''),
smtpPort: coerceNumber(settings.smtpPort, 587),
smtpUser: String(settings.smtpUser || ''),
smtpFromEmail: String(settings.smtpFromEmail || ''),
smtpFromName: String(settings.smtpFromName || ''),
smtpTlsMode: TLS_MODES.includes(settings.smtpTlsMode) ? settings.smtpTlsMode : 'starttls',
smtpTimeoutMs: coerceNumber(settings.smtpTimeoutMs, 10000),
hasSmtpPassword: !!encryptedPassword,
};
if (includePassword) {
smtpConfig.smtpPassword = encryptedPassword ? decrypt(encryptedPassword) : '';
smtpConfig.smtpPasswordEncrypted = encryptedPassword;
}
return smtpConfig;
}
function validateSmtpConfig(input, { allowEmpty = true } = {}) {
const host = (input.smtpHost || '').trim();
const user = (input.smtpUser || '').trim();
const fromEmail = (input.smtpFromEmail || '').trim();
const fromName = (input.smtpFromName || '').trim();
const port = coerceNumber(input.smtpPort, 0);
const timeoutMs = coerceNumber(input.smtpTimeoutMs, 0);
const tlsMode = (input.smtpTlsMode || 'starttls').trim().toLowerCase();
const anyProvided = [host, user, fromEmail, input.smtpPassword || '', fromName].some(Boolean);
if (allowEmpty && !anyProvided) {
return { valid: true };
}
if (!host) return { valid: false, error: 'SMTP host is required when email notifications are configured.' };
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { valid: false, error: 'SMTP port must be between 1 and 65535.' };
}
if (!user) return { valid: false, error: 'SMTP username is required when email notifications are configured.' };
if (!fromEmail) return { valid: false, error: 'From email is required for outgoing notifications.' };
if (!/^\S+@\S+\.\S+$/.test(fromEmail)) {
return { valid: false, error: 'From email must be a valid email address.' };
}
if (!TLS_MODES.includes(tlsMode)) {
return { valid: false, error: `TLS mode must be one of: ${TLS_MODES.join(', ')}.` };
}
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 120000) {
return { valid: false, error: 'SMTP timeout must be between 1000ms and 120000ms.' };
}
return {
valid: true,
normalized: {
smtpHost: host,
smtpPort: port,
smtpUser: user,
smtpFromEmail: fromEmail,
smtpFromName: fromName,
smtpTlsMode: tlsMode,
smtpTimeoutMs: timeoutMs,
},
};
}
async function saveSmtpConfig(input, { preservePassword = true, allowEmpty = true } = {}) {
const current = await getSmtpConfig({ includePassword: true });
const providedPassword = typeof input.smtpPassword === 'string' ? input.smtpPassword : '';
// Determine the plaintext password to use for validation
const validationPlain = providedPassword || (preservePassword ? current.smtpPassword : '');
const validation = validateSmtpConfig({ ...input, smtpPassword: validationPlain }, { allowEmpty });
if (!validation.valid) {
return { success: false, error: validation.error };
}
const normalized = validation.normalized || {
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpFromEmail: '',
smtpFromName: '',
smtpTlsMode: 'starttls',
smtpTimeoutMs: 10000,
};
// Build the encrypted value without doubleencrypting the stored password
let nextPasswordEncrypted = '';
if (providedPassword) {
nextPasswordEncrypted = encrypt(providedPassword);
} else if (preservePassword && current.smtpPasswordEncrypted) {
nextPasswordEncrypted = current.smtpPasswordEncrypted; // keep raw encrypted blob
}
await setSettings({
smtpHost: normalized.smtpHost,
smtpPort: String(normalized.smtpPort),
smtpUser: normalized.smtpUser,
smtpFromEmail: normalized.smtpFromEmail,
smtpFromName: normalized.smtpFromName,
smtpTlsMode: normalized.smtpTlsMode,
smtpTimeoutMs: String(normalized.smtpTimeoutMs),
smtpPassword: nextPasswordEncrypted,
});
return { success: true };
}
module.exports = {
TLS_MODES,
getSettingsMap,
getSetting,
setSettings,
getSmtpConfig,
validateSmtpConfig,
saveSmtpConfig,
};

View File

@@ -0,0 +1,83 @@
const nodemailer = require('nodemailer');
const { getSmtpConfig, setSettings, getSetting } = require('./settingsService');
function buildTransportOptions(config) {
const base = {
host: config.smtpHost,
port: Number(config.smtpPort),
auth: {
user: config.smtpUser,
pass: config.smtpPassword,
},
connectionTimeout: Number(config.smtpTimeoutMs),
greetingTimeout: Number(config.smtpTimeoutMs),
socketTimeout: Number(config.smtpTimeoutMs),
};
if (config.smtpTlsMode === 'tls') {
return { ...base, secure: true };
}
if (config.smtpTlsMode === 'starttls') {
return { ...base, secure: false, requireTLS: true };
}
return { ...base, secure: false, ignoreTLS: true };
}
function formatFromAddress(config) {
const email = config.smtpFromEmail || config.smtpUser;
if (config.smtpFromName) {
return `${config.smtpFromName} <${email}>`;
}
return email;
}
async function verifySmtpConnection() {
const config = await getSmtpConfig({ includePassword: true });
if (!config.smtpHost || !config.smtpUser || !config.smtpPassword || !config.smtpFromEmail) {
throw new Error('SMTP is not fully configured. Host, username, password, and from email are required.');
}
const transport = nodemailer.createTransport(buildTransportOptions(config));
await transport.verify();
return config;
}
async function sendMail({ to, subject, text, html }) {
const config = await getSmtpConfig({ includePassword: true });
if (!config.smtpHost || !config.smtpUser || !config.smtpPassword || !config.smtpFromEmail) {
throw new Error('SMTP is not fully configured.');
}
const transport = nodemailer.createTransport(buildTransportOptions(config));
const result = await transport.sendMail({
from: formatFromAddress(config),
to,
subject,
text,
html,
});
await setSettings({
smtpLastSuccessAt: new Date().toISOString(),
smtpLastError: '',
smtpFailureStreak: '0',
});
return result;
}
async function registerSmtpFailure(error) {
const streakSetting = await getSetting('smtpFailureStreak', '0');
const failureStreak = Number(streakSetting || 0) + 1;
await setSettings({
smtpLastError: error.message || 'Unknown SMTP error',
smtpLastErrorAt: new Date().toISOString(),
smtpFailureStreak: String(failureStreak),
});
}
module.exports = {
verifySmtpConnection,
sendMail,
registerSmtpFailure,
};

View File

@@ -0,0 +1,68 @@
const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const TAG_LENGTH = 16;
/**
* Derives a 256-bit encryption key from a dedicated ENCRYPTION_KEY,
* falling back to JWT_SECRET for backward compatibility.
* Using a separate key ensures that a JWT_SECRET leak does not also
* compromise encrypted data (SMTP passwords, etc.).
*/
function getEncryptionKey() {
const secret = process.env.ENCRYPTION_KEY || process.env.JWT_SECRET;
if (!secret) throw new Error('ENCRYPTION_KEY (or JWT_SECRET) is required for encryption');
return crypto.createHash('sha256').update(secret).digest();
}
/**
* Encrypt a plaintext string.
* Returns a hex string in the format: iv:encrypted:authTag
*/
function encrypt(text) {
if (!text) return '';
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${encrypted}:${authTag}`;
}
/**
* Decrypt a string encrypted with encrypt().
* Expects format: iv:encrypted:authTag
*/
function decrypt(encryptedText) {
if (!encryptedText) return '';
// If it doesn't look like our encrypted format, return as-is
// (handles legacy unencrypted values)
const parts = encryptedText.split(':');
if (parts.length !== 3) return encryptedText;
try {
const key = getEncryptionKey();
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const authTag = Buffer.from(parts[2], 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch {
// If decryption fails, it may be a legacy plaintext value
return encryptedText;
}
}
module.exports = { encrypt, decrypt };

View File

@@ -0,0 +1,64 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { initializeDatabase } = require('../src/models/database');
const { start } = require('../src/server');
function getBaseUrl(server) {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve server address');
}
return `http://127.0.0.1:${address.port}`;
}
test('smoke: core API routes are reachable', async (t) => {
await initializeDatabase();
process.env.PORT = '0';
const { server } = await start();
const baseUrl = getBaseUrl(server);
try {
await t.test('health endpoint responds', async () => {
const response = await fetch(`${baseUrl}/health`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, 'ok');
});
await t.test('v1 status endpoint responds with expected shape', async () => {
const response = await fetch(`${baseUrl}/api/v1/status.json`);
assert.equal(response.status, 200);
const body = await response.json();
assert.ok(body.page);
assert.ok(Array.isArray(body.components));
assert.ok(Array.isArray(body.component_groups));
assert.ok(Array.isArray(body.incidents));
assert.ok(Array.isArray(body.scheduled_maintenances));
});
await t.test('admin endpoint create is protected', async () => {
const response = await fetch(`${baseUrl}/api/admin/endpoints`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Smoke', url: 'https://example.com', type: 'http' }),
});
assert.equal(response.status, 401);
});
await t.test('admin incident create is protected', async () => {
const response = await fetch(`${baseUrl}/api/admin/incidents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Smoke incident', severity: 'degraded' }),
});
assert.equal(response.status, 401);
});
} finally {
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
});

41
frontend/package.json Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More