feat: initial implementation of Oriana uptime monitor
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 50s

Implements a full-stack uptime monitoring application with:
- HTTPS, HTTPS keyword, HTTPS status, port, and MongoDB Atlas monitor types
- Cron-based monitoring engine with webhook notifications on status changes
- Discord OAuth2 admin panel (single-owner)
- Public status page with category grouping and failure reason display
- Admin dashboard with sortable monitors table and detailed failure info
- SQLite persistence with migration support
This commit is contained in:
2026-03-05 17:25:50 -08:00
committed by Naomi Carrigan
parent f77d2ed273
commit 1e3b06036d
38 changed files with 9887 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
prod
dist
coverage
*.db
*.db-shm
*.db-wal
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oriana</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "oriana-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router-dom": "7.2.0",
"recharts": "2.15.1"
},
"devDependencies": {
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.8.2",
"vite": "6.2.1"
}
}
+1437
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "./hooks/useAuth.js";
import { StatusPage } from "./pages/StatusPage.js";
import { Login } from "./pages/Login.js";
import { Dashboard } from "./pages/Dashboard.js";
import { Monitors } from "./pages/Monitors.js";
import { Incidents } from "./pages/Incidents.js";
const ProtectedRoute = ({
children,
}: {
children: React.ReactNode;
}): React.ReactElement => {
const { user, loading } = useAuth();
if (loading) {
return <div className="loading">Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
export const App = (): React.ReactElement => {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<StatusPage />} />
<Route path="/login" element={<Login />} />
<Route
path="/admin"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/admin/monitors"
element={
<ProtectedRoute>
<Monitors />
</ProtectedRoute>
}
/>
<Route
path="/admin/incidents"
element={
<ProtectedRoute>
<Incidents />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
};
+87
View File
@@ -0,0 +1,87 @@
import { Link, useLocation } from "react-router-dom";
import type { CurrentUser } from "../types.js";
const navLinks = [
{ to: "/admin", label: "Dashboard" },
{ to: "/admin/monitors", label: "Monitors" },
{ to: "/admin/incidents", label: "Incidents & Maintenance" },
];
export const AdminNav = ({
user,
}: {
user: CurrentUser;
}): React.ReactElement => {
const { pathname } = useLocation();
const handleLogout = async (): Promise<void> => {
await fetch("/auth/logout", { method: "POST", credentials: "include" });
window.location.href = "/";
};
return (
<nav
style={{
background: "#0a0009",
color: "#f5f5f5",
padding: "0 24px",
display: "flex",
alignItems: "center",
gap: 24,
height: 56,
borderBottom: "1px solid #44275a",
}}
>
<span style={{ fontWeight: 700, fontSize: 18, marginRight: 8, fontFamily: "Griffy, cursive", color: "#d4a5c7" }}>
Oriana
</span>
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
style={{
color: pathname === link.to ? "#e8d5e8" : "#d4a5c7",
textDecoration: "none",
fontSize: 14,
fontWeight: pathname === link.to ? 700 : 400,
transition: "opacity 0.3s ease",
}}
>
{link.label}
</Link>
))}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 16 }}>
<Link
to="/"
style={{
color: "#d4a5c7",
textDecoration: "none",
fontSize: 13,
transition: "opacity 0.3s ease",
}}
>
Status Page
</Link>
<span style={{ fontSize: 13, color: "#d4a5c7" }}>
{user.globalName ?? user.username}
</span>
<button
onClick={() => void handleLogout()}
style={{
background: "transparent",
border: "1px solid #a8577e",
color: "#d4a5c7",
borderRadius: 6,
padding: "4px 12px",
cursor: "pointer",
fontSize: 13,
fontFamily: "Kalam, sans-serif",
transition: "all 0.3s ease",
}}
>
Log out
</button>
</div>
</nav>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import type { CurrentUser } from "../types.js";
export const useAuth = (): { user: CurrentUser | null; loading: boolean } => {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/auth/me", { credentials: "include" })
.then(async (res) => {
if (res.ok) {
setUser((await res.json()) as CurrentUser);
}
})
.finally(() => {
setLoading(false);
});
}, []);
return { user, loading };
};
+51
View File
@@ -0,0 +1,51 @@
@import url("https://fonts.googleapis.com/css2?family=Griffy&family=Kalam:wght@300;400;700&display=swap");
:root {
--witch-purple: #2b1b3d;
--witch-plum: #44275a;
--witch-rose: #a8577e;
--witch-mauve: #d4a5c7;
--witch-lavender: #e8d5e8;
--witch-black: #0a0009;
--witch-silver: #c0c0c0;
--witch-moon: #f5f5f5;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--witch-purple);
color: var(--witch-moon);
font-family: "Kalam", sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Griffy", cursive;
}
a {
color: var(--witch-rose);
transition: opacity 0.3s ease;
}
a:hover {
opacity: 0.8;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--witch-mauve);
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./App.js";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found.");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { useAuth } from "../hooks/useAuth.js";
import { AdminNav } from "../components/AdminNav.js";
import type { Incident, Monitor, StatusResponse } from "../types.js";
export const Dashboard = (): React.ReactElement => {
const { user } = useAuth();
const [status, setStatus] = useState<StatusResponse | null>(null);
const [monitors, setMonitors] = useState<Monitor[]>([]);
useEffect(() => {
fetch("/api/status")
.then(async (res) => res.json())
.then((d) => {
setStatus(d as StatusResponse);
});
fetch("/api/monitors", { credentials: "include" })
.then(async (res) => res.json())
.then((d) => {
setMonitors(d as Monitor[]);
});
}, []);
const upCount = status?.monitors.filter((m) => m.status === "up").length ?? 0;
const downCount = status?.monitors.filter((m) => m.status === "down").length ?? 0;
const activeIncidents = status?.incidents.filter(
(i: Incident) => i.status !== "resolved",
).length ?? 0;
return (
<div style={{ minHeight: "100vh" }}>
{user && <AdminNav user={user} />}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "32px 16px" }}>
<h1 style={{ margin: "0 0 24px", fontSize: 24, color: "#d4a5c7" }}>Dashboard</h1>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16, marginBottom: 32 }}>
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 20 }}>
<div style={{ fontSize: 32, fontWeight: 700, color: "#4ade80", fontFamily: "Griffy, cursive" }}>{upCount}</div>
<div style={{ color: "#d4a5c7", fontSize: 14 }}>Monitors Up</div>
</div>
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 20 }}>
<div style={{ fontSize: 32, fontWeight: 700, color: "#f87171", fontFamily: "Griffy, cursive" }}>{downCount}</div>
<div style={{ color: "#d4a5c7", fontSize: 14 }}>Monitors Down</div>
</div>
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 20 }}>
<div style={{ fontSize: 32, fontWeight: 700, color: "#fb923c", fontFamily: "Griffy, cursive" }}>{activeIncidents}</div>
<div style={{ color: "#d4a5c7", fontSize: 14 }}>Active Incidents</div>
</div>
</div>
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid #2b1b3d", fontWeight: 600, color: "#e8d5e8" }}>
All Monitors
</div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ background: "#0a0009", fontSize: 12, color: "#d4a5c7" }}>
<th style={{ padding: "8px 20px", textAlign: "left" }}>Name</th>
<th style={{ padding: "8px 20px", textAlign: "left" }}>Type</th>
<th style={{ padding: "8px 20px", textAlign: "left" }}>Category</th>
<th style={{ padding: "8px 20px", textAlign: "left" }}>Status / Details</th>
<th style={{ padding: "8px 20px", textAlign: "left" }}>Interval</th>
<th style={{ padding: "8px 20px", textAlign: "left" }}>Enabled</th>
</tr>
</thead>
<tbody>
{monitors.map((m) => {
const summary = status?.monitors.find((s) => s.id === m.id);
const statusColour =
summary?.status === "up"
? "#4ade80"
: summary?.status === "down"
? "#f87171"
: "#c0c0c0";
const checkedAt = summary?.lastChecked
? new Date(summary.lastChecked).toLocaleString()
: null;
return (
<tr key={m.id} style={{ borderTop: "1px solid #2b1b3d", fontSize: 14 }}>
<td style={{ padding: "10px 20px", color: "#f5f5f5" }}>{m.name}</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.type}</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.category}</td>
<td style={{ padding: "10px 20px" }}>
<span style={{ fontWeight: 600, color: statusColour }}>
{(summary?.status ?? "unknown").toUpperCase()}
</span>
{summary?.status === "down" && (
<div style={{ marginTop: 4, fontSize: 12, color: "#f87171" }}>
{summary.message ?? "No details available."}
{summary.statusCode !== null && (
<span style={{ marginLeft: 6, color: "#c0c0c0" }}>
(HTTP {summary.statusCode})
</span>
)}
{summary.responseTimeMs !== null && (
<span style={{ marginLeft: 6, color: "#c0c0c0" }}>
· {summary.responseTimeMs}ms
</span>
)}
{checkedAt !== null && (
<div style={{ color: "#c0c0c0", marginTop: 2 }}>
Last checked: {checkedAt}
</div>
)}
</div>
)}
{summary?.status === "up" && (
<div style={{ marginTop: 2, fontSize: 12, color: "#c0c0c0" }}>
{summary.responseTimeMs !== null && `${summary.responseTimeMs}ms`}
{summary.responseTimeMs !== null && checkedAt !== null && " · "}
{checkedAt !== null && checkedAt}
</div>
)}
</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.intervalSeconds}s</td>
<td style={{ padding: "10px 20px", color: "#f5f5f5" }}>{m.enabled ? "✓" : "✗"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
+285
View File
@@ -0,0 +1,285 @@
import { useEffect, useReducer, useState } from "react";
import { useAuth } from "../hooks/useAuth.js";
import { AdminNav } from "../components/AdminNav.js";
import type { Incident, IncidentStatus, MaintenanceWindow } from "../types.js";
type IncidentForm = { title: string; message: string; status: IncidentStatus };
type MaintenanceForm = {
title: string;
message: string;
startTime: string;
endTime: string;
};
const defaultIncidentForm: IncidentForm = {
title: "",
message: "",
status: "investigating",
};
const defaultMaintenanceForm: MaintenanceForm = {
title: "",
message: "",
startTime: "",
endTime: "",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid #44275a",
borderRadius: 8,
fontSize: 14,
boxSizing: "border-box",
background: "#2b1b3d",
color: "#f5f5f5",
fontFamily: "Kalam, sans-serif",
};
const statusColour: Record<IncidentStatus, string> = {
investigating: "#fb923c",
identified: "#facc15",
monitoring: "#60a5fa",
resolved: "#4ade80",
};
export const Incidents = (): React.ReactElement => {
const { user } = useAuth();
const [incidents, setIncidents] = useState<Incident[]>([]);
const [maintenance, setMaintenance] = useState<MaintenanceWindow[]>([]);
const [showIncidentForm, setShowIncidentForm] = useState(false);
const [showMaintenanceForm, setShowMaintenanceForm] = useState(false);
const [editingIncident, setEditingIncident] = useState<Incident | null>(null);
const [editingMaintenance, setEditingMaintenance] = useState<MaintenanceWindow | null>(null);
const [incidentForm, setIncidentForm] = useReducer(
(state: IncidentForm, updates: Partial<IncidentForm>) => ({ ...state, ...updates }),
defaultIncidentForm,
);
const [maintenanceForm, setMaintenanceForm] = useReducer(
(state: MaintenanceForm, updates: Partial<MaintenanceForm>) => ({ ...state, ...updates }),
defaultMaintenanceForm,
);
const load = (): void => {
fetch("/api/incidents")
.then(async (res) => res.json())
.then((d) => { setIncidents(d as Incident[]); });
fetch("/api/maintenance")
.then(async (res) => res.json())
.then((d) => { setMaintenance(d as MaintenanceWindow[]); });
};
useEffect(() => { load(); }, []);
const submitIncident = async (): Promise<void> => {
const url = editingIncident ? `/api/incidents/${editingIncident.id}` : "/api/incidents";
const method = editingIncident ? "PUT" : "POST";
await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(incidentForm),
});
setShowIncidentForm(false);
load();
};
const deleteIncident = async (id: string): Promise<void> => {
if (!confirm("Delete this incident?")) return;
await fetch(`/api/incidents/${id}`, { method: "DELETE", credentials: "include" });
load();
};
const submitMaintenance = async (): Promise<void> => {
const url = editingMaintenance ? `/api/maintenance/${editingMaintenance.id}` : "/api/maintenance";
const method = editingMaintenance ? "PUT" : "POST";
await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(maintenanceForm),
});
setShowMaintenanceForm(false);
load();
};
const deleteMaintenance = async (id: string): Promise<void> => {
if (!confirm("Delete this maintenance window?")) return;
await fetch(`/api/maintenance/${id}`, { method: "DELETE", credentials: "include" });
load();
};
const primaryButton: React.CSSProperties = {
background: "#a8577e",
color: "#f5f5f5",
border: "none",
borderRadius: 8,
padding: "10px 20px",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
fontFamily: "Kalam, sans-serif",
transition: "opacity 0.3s ease",
};
const secondaryButton: React.CSSProperties = {
background: "transparent",
color: "#d4a5c7",
border: "1px solid #44275a",
borderRadius: 8,
padding: "8px 20px",
cursor: "pointer",
fontSize: 14,
fontFamily: "Kalam, sans-serif",
};
return (
<div style={{ minHeight: "100vh" }}>
{user && <AdminNav user={user} />}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "32px 16px" }}>
{/* Incidents */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h1 style={{ margin: 0, fontSize: 24, color: "#d4a5c7" }}>Incidents</h1>
<button
onClick={() => {
setEditingIncident(null);
setIncidentForm(defaultIncidentForm);
setShowIncidentForm(true);
}}
style={primaryButton}
>
New Incident
</button>
</div>
{showIncidentForm && (
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 24, marginBottom: 24 }}>
<h2 style={{ margin: "0 0 16px", fontSize: 18, color: "#e8d5e8" }}>{editingIncident ? "Edit Incident" : "New Incident"}</h2>
<div style={{ display: "grid", gap: 12 }}>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Title</label>
<input style={inputStyle} value={incidentForm.title} onChange={(e) => { setIncidentForm({ title: e.target.value }); }} />
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Message</label>
<textarea style={{ ...inputStyle, height: 80, resize: "vertical" }} value={incidentForm.message} onChange={(e) => { setIncidentForm({ message: e.target.value }); }} />
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Status</label>
<select style={inputStyle} value={incidentForm.status} onChange={(e) => { setIncidentForm({ status: e.target.value as IncidentStatus }); }}>
<option value="investigating">Investigating</option>
<option value="identified">Identified</option>
<option value="monitoring">Monitoring</option>
<option value="resolved">Resolved</option>
</select>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button onClick={() => void submitIncident()} style={primaryButton}>
{editingIncident ? "Save Changes" : "Create Incident"}
</button>
<button onClick={() => { setShowIncidentForm(false); }} style={secondaryButton}>
Cancel
</button>
</div>
</div>
)}
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, marginBottom: 40, overflow: "hidden" }}>
{incidents.length === 0 ? (
<div style={{ padding: 40, textAlign: "center", color: "#c0c0c0" }}>No incidents.</div>
) : incidents.map((incident) => (
<div key={incident.id} style={{ padding: 16, borderBottom: "1px solid #2b1b3d" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<strong style={{ fontSize: 15, color: "#f5f5f5" }}>{incident.title}</strong>
<span style={{ marginLeft: 10, fontSize: 12, fontWeight: 600, color: statusColour[incident.status] }}>{incident.status}</span>
<div style={{ fontSize: 13, color: "#d4a5c7", marginTop: 4 }}>{incident.message}</div>
<div style={{ fontSize: 11, color: "#c0c0c0", marginTop: 4 }}>
Created {new Date(incident.createdAt).toLocaleString()} · Updated {new Date(incident.updatedAt).toLocaleString()}
</div>
</div>
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<button onClick={() => { setEditingIncident(incident); setIncidentForm({ title: incident.title, message: incident.message, status: incident.status }); setShowIncidentForm(true); }} style={{ border: "1px solid #a8577e", background: "transparent", color: "#d4a5c7", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Edit</button>
<button onClick={() => void deleteIncident(incident.id)} style={{ border: "1px solid #f87171", color: "#f87171", background: "transparent", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Delete</button>
</div>
</div>
</div>
))}
</div>
{/* Maintenance */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 22, color: "#d4a5c7" }}>Maintenance Windows</h2>
<button
onClick={() => {
setEditingMaintenance(null);
setMaintenanceForm(defaultMaintenanceForm);
setShowMaintenanceForm(true);
}}
style={primaryButton}
>
New Window
</button>
</div>
{showMaintenanceForm && (
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 24, marginBottom: 24 }}>
<h2 style={{ margin: "0 0 16px", fontSize: 18, color: "#e8d5e8" }}>{editingMaintenance ? "Edit Maintenance Window" : "New Maintenance Window"}</h2>
<div style={{ display: "grid", gap: 12 }}>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Title</label>
<input style={inputStyle} value={maintenanceForm.title} onChange={(e) => { setMaintenanceForm({ title: e.target.value }); }} />
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Message</label>
<textarea style={{ ...inputStyle, height: 60, resize: "vertical" }} value={maintenanceForm.message} onChange={(e) => { setMaintenanceForm({ message: e.target.value }); }} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Start Time</label>
<input type="datetime-local" style={inputStyle} value={maintenanceForm.startTime} onChange={(e) => { setMaintenanceForm({ startTime: e.target.value }); }} />
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>End Time</label>
<input type="datetime-local" style={inputStyle} value={maintenanceForm.endTime} onChange={(e) => { setMaintenanceForm({ endTime: e.target.value }); }} />
</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button onClick={() => void submitMaintenance()} style={primaryButton}>
{editingMaintenance ? "Save Changes" : "Create Window"}
</button>
<button onClick={() => { setShowMaintenanceForm(false); }} style={secondaryButton}>
Cancel
</button>
</div>
</div>
)}
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, overflow: "hidden" }}>
{maintenance.length === 0 ? (
<div style={{ padding: 40, textAlign: "center", color: "#c0c0c0" }}>No maintenance windows.</div>
) : maintenance.map((w) => (
<div key={w.id} style={{ padding: 16, borderBottom: "1px solid #2b1b3d" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<strong style={{ fontSize: 15, color: "#f5f5f5" }}>{w.title}</strong>
<div style={{ fontSize: 13, color: "#d4a5c7", marginTop: 4 }}>{w.message}</div>
<div style={{ fontSize: 11, color: "#c0c0c0", marginTop: 4 }}>
{new Date(w.startTime).toLocaleString()} {new Date(w.endTime).toLocaleString()}
</div>
</div>
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<button onClick={() => { setEditingMaintenance(w); setMaintenanceForm({ title: w.title, message: w.message, startTime: w.startTime, endTime: w.endTime }); setShowMaintenanceForm(true); }} style={{ border: "1px solid #a8577e", background: "transparent", color: "#d4a5c7", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Edit</button>
<button onClick={() => void deleteMaintenance(w.id)} style={{ border: "1px solid #f87171", color: "#f87171", background: "transparent", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Delete</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth.js";
export const Login = (): React.ReactElement => {
const { user, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && user) {
navigate("/admin");
}
}, [user, loading, navigate]);
const error = new URLSearchParams(window.location.search).get("error");
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
}}
>
<div
style={{
background: "#44275a",
border: "1px solid #a8577e",
borderRadius: 15,
padding: 40,
textAlign: "center",
maxWidth: 360,
width: "100%",
}}
>
<h1 style={{ margin: "0 0 8px", fontSize: 28, color: "#d4a5c7" }}>Oriana</h1>
<p style={{ color: "#c0c0c0", margin: "0 0 24px", fontSize: 14 }}>
Admin access via Discord
</p>
{error === "unauthorised" && (
<div
style={{
background: "#0a0009",
border: "1px solid #f87171",
borderRadius: 8,
padding: "10px 14px",
color: "#f87171",
fontSize: 13,
marginBottom: 16,
}}
>
Your Discord account is not authorised to access this dashboard.
</div>
)}
<a
href="/auth/discord"
style={{
display: "inline-block",
background: "#5865f2",
color: "#fff",
padding: "12px 24px",
borderRadius: 8,
textDecoration: "none",
fontWeight: 600,
fontSize: 15,
transition: "opacity 0.3s ease",
}}
>
Sign in with Discord
</a>
<div style={{ marginTop: 20 }}>
<a href="/" style={{ fontSize: 13, color: "#d4a5c7" }}>
Back to status page
</a>
</div>
</div>
</div>
);
};
+519
View File
@@ -0,0 +1,519 @@
import { useEffect, useReducer, useState } from "react";
import { useAuth } from "../hooks/useAuth.js";
import { AdminNav } from "../components/AdminNav.js";
import type { Monitor, MonitorType } from "../types.js";
type FormState = {
name: string;
type: MonitorType;
url: string;
host: string;
port: string;
keyword: string;
allowedStatusCodes: string;
deniedStatusCodes: string;
intervalSeconds: string;
category: string;
isPublic: boolean;
};
const defaultForm: FormState = {
name: "",
type: "https",
url: "",
host: "",
port: "",
keyword: "",
allowedStatusCodes: "",
deniedStatusCodes: "",
intervalSeconds: "60",
category: "General",
isPublic: true,
};
type SortKey = "category" | "enabled" | "intervalSeconds" | "isPublic" | "name" | "target" | "type";
type SortDir = "asc" | "desc";
const sortAccessors: Record<SortKey, (m: Monitor) => number | string> = {
category: (m) => m.category.toLowerCase(),
enabled: (m) => m.enabled,
intervalSeconds: (m) => m.intervalSeconds,
isPublic: (m) => m.isPublic,
name: (m) => m.name.toLowerCase(),
target: (m): string => {
const raw = m.url ?? (m.host !== null && m.port !== null
? `${m.host}:${m.port}`
: "");
return raw.toLowerCase();
},
type: (m) => m.type,
};
export const Monitors = (): React.ReactElement => {
const { user } = useAuth();
const [monitors, setMonitors] = useState<Monitor[]>([]);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Monitor | null>(null);
const [form, setForm] = useReducer(
(state: FormState, updates: Partial<FormState>) => ({ ...state, ...updates }),
defaultForm,
);
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const handleSort = (key: SortKey): void => {
if (sortKey === key) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir("asc");
}
};
const sortIcon = (col: SortKey): string => {
if (sortKey !== col) { return " ↕"; }
return sortDir === "asc" ? " ↑" : " ↓";
};
const sortedMonitors = [...monitors].sort((a, b) => {
const aVal = sortAccessors[sortKey](a);
const bVal = sortAccessors[sortKey](b);
if (aVal < bVal) {
return sortDir === "asc" ? -1 : 1;
}
if (aVal > bVal) {
return sortDir === "asc" ? 1 : -1;
}
return 0;
});
const loadMonitors = (): void => {
fetch("/api/monitors", { credentials: "include" })
.then(async (res) => res.json())
.then((d) => {
setMonitors(d as Monitor[]);
});
};
useEffect(() => {
loadMonitors();
}, []);
const openCreate = (): void => {
setEditing(null);
setForm(defaultForm);
setShowForm(true);
};
const openEdit = (monitor: Monitor): void => {
setEditing(monitor);
setForm({
name: monitor.name,
type: monitor.type,
url: monitor.url ?? "",
host: monitor.host ?? "",
port: monitor.port?.toString() ?? "",
keyword: monitor.keyword ?? "",
allowedStatusCodes: monitor.allowedStatusCodes
? (JSON.parse(monitor.allowedStatusCodes) as number[]).join(", ")
: "",
deniedStatusCodes: monitor.deniedStatusCodes
? (JSON.parse(monitor.deniedStatusCodes) as number[]).join(", ")
: "",
intervalSeconds: monitor.intervalSeconds.toString(),
category: monitor.category,
isPublic: monitor.isPublic === 1,
});
setShowForm(true);
};
const handleSubmit = async (): Promise<void> => {
const body = {
name: form.name,
type: form.type,
url: form.url || undefined,
host: form.host || undefined,
port: form.port ? Number(form.port) : undefined,
keyword: form.keyword || undefined,
allowedStatusCodes: form.allowedStatusCodes
? form.allowedStatusCodes.split(",").map((s) => Number(s.trim()))
: undefined,
deniedStatusCodes: form.deniedStatusCodes
? form.deniedStatusCodes.split(",").map((s) => Number(s.trim()))
: undefined,
intervalSeconds: Number(form.intervalSeconds),
category: form.category,
isPublic: form.isPublic,
};
const url = editing ? `/api/monitors/${editing.id}` : "/api/monitors";
const method = editing ? "PUT" : "POST";
await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setShowForm(false);
loadMonitors();
};
const handleDelete = async (id: string): Promise<void> => {
if (!confirm("Delete this monitor?")) return;
await fetch(`/api/monitors/${id}`, { method: "DELETE", credentials: "include" });
loadMonitors();
};
const handleToggleEnabled = async (monitor: Monitor): Promise<void> => {
await fetch(`/api/monitors/${monitor.id}`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !monitor.enabled }),
});
loadMonitors();
};
const handleTogglePublic = async (monitor: Monitor): Promise<void> => {
await fetch(`/api/monitors/${monitor.id}`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPublic: !monitor.isPublic }),
});
loadMonitors();
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid #44275a",
borderRadius: 8,
fontSize: 14,
boxSizing: "border-box",
background: "#2b1b3d",
color: "#f5f5f5",
fontFamily: "Kalam, sans-serif",
};
return (
<div style={{ minHeight: "100vh" }}>
{user && <AdminNav user={user} />}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "32px 16px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
<h1 style={{ margin: 0, fontSize: 24, color: "#d4a5c7" }}>Monitors</h1>
<button
onClick={openCreate}
style={{
background: "#a8577e",
color: "#f5f5f5",
border: "none",
borderRadius: 8,
padding: "10px 20px",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
fontFamily: "Kalam, sans-serif",
transition: "opacity 0.3s ease",
}}
>
Add Monitor
</button>
</div>
{showForm && (
<div
style={{
background: "#44275a",
border: "1px solid #a8577e",
borderRadius: 12,
padding: 24,
marginBottom: 24,
}}
>
<h2 style={{ margin: "0 0 16px", fontSize: 18, color: "#e8d5e8" }}>
{editing ? "Edit Monitor" : "New Monitor"}
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Name</label>
<input
style={inputStyle}
value={form.name}
onChange={(e) => { setForm({ name: e.target.value }); }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Type</label>
<select
style={inputStyle}
value={form.type}
onChange={(e) => { setForm({ type: e.target.value as MonitorType }); }}
>
<option value="https">HTTPS</option>
<option value="https_keyword">HTTPS + Keyword</option>
<option value="https_status">HTTPS + Status Codes</option>
<option value="mongodb">MongoDB Atlas</option>
<option value="port">Port</option>
</select>
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Category</label>
<input
style={inputStyle}
value={form.category}
onChange={(e) => { setForm({ category: e.target.value }); }}
placeholder="General"
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>
Interval (seconds)
</label>
<input
style={inputStyle}
type="number"
value={form.intervalSeconds}
onChange={(e) => { setForm({ intervalSeconds: e.target.value }); }}
/>
</div>
{(form.type === "https" || form.type === "https_keyword" || form.type === "https_status") && (
<div style={{ gridColumn: "span 2" }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>URL</label>
<input
style={inputStyle}
value={form.url}
onChange={(e) => { setForm({ url: e.target.value }); }}
placeholder="https://example.com"
/>
</div>
)}
{form.type === "mongodb" && (
<div style={{ gridColumn: "span 2" }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Connection String</label>
<input
style={inputStyle}
value={form.url}
onChange={(e) => { setForm({ url: e.target.value }); }}
placeholder="mongodb+srv://user:password@cluster.mongodb.net/"
/>
</div>
)}
{form.type === "https_keyword" && (
<div style={{ gridColumn: "span 2" }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Keyword</label>
<input
style={inputStyle}
value={form.keyword}
onChange={(e) => { setForm({ keyword: e.target.value }); }}
placeholder="Expected text in response"
/>
</div>
)}
{form.type === "https_status" && (
<>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>
Allowed Status Codes (comma-separated)
</label>
<input
style={inputStyle}
value={form.allowedStatusCodes}
onChange={(e) => { setForm({ allowedStatusCodes: e.target.value }); }}
placeholder="200, 201"
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>
Denied Status Codes (comma-separated)
</label>
<input
style={inputStyle}
value={form.deniedStatusCodes}
onChange={(e) => { setForm({ deniedStatusCodes: e.target.value }); }}
placeholder="500, 503"
/>
</div>
</>
)}
{form.type === "port" && (
<>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Host</label>
<input
style={inputStyle}
value={form.host}
onChange={(e) => { setForm({ host: e.target.value }); }}
placeholder="my-server.example.com"
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Port</label>
<input
style={inputStyle}
type="number"
value={form.port}
onChange={(e) => { setForm({ port: e.target.value }); }}
placeholder="22"
/>
</div>
</>
)}
<div style={{ gridColumn: "span 2" }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", color: "#d4a5c7", fontSize: 13 }}>
<input
type="checkbox"
checked={form.isPublic}
onChange={(e) => { setForm({ isPublic: e.target.checked }); }}
style={{ accentColor: "#a8577e", width: 16, height: 16 }}
/>
Show on public status page
</label>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button
onClick={() => void handleSubmit()}
style={{
background: "#a8577e",
color: "#f5f5f5",
border: "none",
borderRadius: 8,
padding: "8px 20px",
cursor: "pointer",
fontSize: 14,
fontFamily: "Kalam, sans-serif",
transition: "opacity 0.3s ease",
}}
>
{editing ? "Save Changes" : "Create Monitor"}
</button>
<button
onClick={() => { setShowForm(false); }}
style={{
background: "transparent",
color: "#d4a5c7",
border: "1px solid #44275a",
borderRadius: 8,
padding: "8px 20px",
cursor: "pointer",
fontSize: 14,
fontFamily: "Kalam, sans-serif",
}}
>
Cancel
</button>
</div>
</div>
)}
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, overflow: "hidden" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ background: "#0a0009", fontSize: 12, color: "#d4a5c7" }}>
{(["name", "type", "category", "target", "intervalSeconds", "enabled", "isPublic"] as SortKey[]).map((col) => (
<th
key={col}
onClick={() => { handleSort(col); }}
style={{ padding: "8px 20px", textAlign: "left", cursor: "pointer", userSelect: "none" }}
>
{col === "intervalSeconds" ? "Interval" : col === "isPublic" ? "Public" : col.charAt(0).toUpperCase() + col.slice(1)}
{sortIcon(col)}
</th>
))}
<th style={{ padding: "8px 20px", textAlign: "left" }}>Actions</th>
</tr>
</thead>
<tbody>
{sortedMonitors.map((m) => (
<tr key={m.id} style={{ borderTop: "1px solid #2b1b3d", fontSize: 14 }}>
<td style={{ padding: "10px 20px", color: "#f5f5f5" }}>{m.name}</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.type}</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.category}</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7", maxWidth: 180, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{m.url ?? (m.host && m.port ? `${m.host}:${m.port}` : "—")}
</td>
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.intervalSeconds}s</td>
<td style={{ padding: "10px 20px" }}>
<button
onClick={() => void handleToggleEnabled(m)}
style={{
background: m.enabled ? "rgba(74, 222, 128, 0.15)" : "rgba(248, 113, 113, 0.15)",
color: m.enabled ? "#4ade80" : "#f87171",
border: `1px solid ${m.enabled ? "#4ade80" : "#f87171"}`,
borderRadius: 4,
padding: "2px 8px",
cursor: "pointer",
fontSize: 12,
fontFamily: "Kalam, sans-serif",
}}
>
{m.enabled ? "On" : "Off"}
</button>
</td>
<td style={{ padding: "10px 20px" }}>
<button
onClick={() => void handleTogglePublic(m)}
style={{
background: m.isPublic ? "rgba(74, 222, 128, 0.15)" : "rgba(192, 192, 192, 0.15)",
color: m.isPublic ? "#4ade80" : "#c0c0c0",
border: `1px solid ${m.isPublic ? "#4ade80" : "#c0c0c0"}`,
borderRadius: 4,
padding: "2px 8px",
cursor: "pointer",
fontSize: 12,
fontFamily: "Kalam, sans-serif",
}}
>
{m.isPublic ? "Visible" : "Hidden"}
</button>
</td>
<td style={{ padding: "10px 20px" }}>
<button
onClick={() => { openEdit(m); }}
style={{
background: "transparent",
border: "1px solid #a8577e",
color: "#d4a5c7",
borderRadius: 4,
padding: "2px 10px",
cursor: "pointer",
fontSize: 12,
marginRight: 6,
fontFamily: "Kalam, sans-serif",
}}
>
Edit
</button>
<button
onClick={() => void handleDelete(m.id)}
style={{
background: "transparent",
border: "1px solid #f87171",
color: "#f87171",
borderRadius: 4,
padding: "2px 10px",
cursor: "pointer",
fontSize: 12,
fontFamily: "Kalam, sans-serif",
}}
>
Delete
</button>
</td>
</tr>
))}
{monitors.length === 0 && (
<tr>
<td colSpan={8} style={{ padding: 40, textAlign: "center", color: "#c0c0c0" }}>
No monitors yet. Add one above!
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { MonitorHistory, StatusResponse } from "../types.js";
const formatTime = (iso: string): string =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const MonitorCard = ({
monitor,
}: {
monitor: StatusResponse["monitors"][number];
}): React.ReactElement => {
const [history, setHistory] = useState<MonitorHistory[]>([]);
useEffect(() => {
fetch(`/api/monitors/${monitor.id}/history?limit=20`)
.then(async (res) => res.json())
.then((data) => {
setHistory((data as MonitorHistory[]).toReversed());
});
}, [monitor.id]);
const chartData = history.map((h) => ({
time: formatTime(h.checkedAt),
ms: h.responseTimeMs,
}));
return (
<div
style={{
border: "1px solid #44275a",
borderRadius: 12,
padding: 16,
marginBottom: 16,
background: "#44275a",
}}
>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<div>
<strong style={{ fontSize: 16, color: "#f5f5f5" }}>{monitor.name}</strong>
<span
style={{
marginLeft: 8,
fontSize: 12,
color: "#d4a5c7",
textTransform: "uppercase",
}}
>
{monitor.type}
</span>
</div>
<span
style={{
fontWeight: 700,
color:
monitor.status === "up"
? "#4ade80"
: monitor.status === "down"
? "#f87171"
: "#c0c0c0",
}}
>
{monitor.status.toUpperCase()}
</span>
</div>
{monitor.status === "down" && monitor.message !== null && (
<div style={{ fontSize: 12, color: "#f87171", marginTop: 4 }}>
{monitor.message}
</div>
)}
{monitor.responseTimeMs !== null && (
<div style={{ fontSize: 12, color: "#d4a5c7", marginTop: 4 }}>
{monitor.responseTimeMs}ms
{monitor.lastChecked &&
` · Last checked ${formatTime(monitor.lastChecked)}`}
</div>
)}
{chartData.length > 0 && (
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData} margin={{ top: 8, right: 0, left: -32, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2b1b3d" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "#d4a5c7" }} />
<YAxis tick={{ fontSize: 9, fill: "#d4a5c7" }} />
<Tooltip
formatter={(value: number) => [`${value}ms`, "Response time"]}
contentStyle={{ background: "#0a0009", border: "1px solid #44275a", color: "#f5f5f5" }}
/>
<Line
type="monotone"
dataKey="ms"
stroke="#a8577e"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
);
};
export const StatusPage = (): React.ReactElement => {
const [data, setData] = useState<StatusResponse | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
const load = (): void => {
fetch("/api/status")
.then(async (res) => res.json())
.then((d) => {
setData(d as StatusResponse);
})
.catch(() => {
setError(true);
});
};
load();
const interval = setInterval(load, 30_000);
return () => {
clearInterval(interval);
};
}, []);
const allUp = data?.monitors.every((m) => m.status === "up");
const grouped = data?.monitors.reduce<Record<string, StatusResponse["monitors"]>>(
(acc, monitor) => {
const { category } = monitor;
if (acc[category] === undefined) {
acc[category] = [];
}
acc[category].push(monitor);
return acc;
},
{},
) ?? {};
const categories = Object.keys(grouped).sort();
return (
<div
style={{
maxWidth: 800,
margin: "0 auto",
padding: "32px 16px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<h1 style={{ margin: 0, fontSize: 28, color: "#d4a5c7" }}>Oriana Status</h1>
<a href="/login" style={{ fontSize: 14, color: "#a8577e" }}>
Admin
</a>
</div>
{error && (
<div style={{ color: "#f87171", marginBottom: 16 }}>
Failed to load status data.
</div>
)}
{data && (
<>
<div
style={{
borderRadius: 12,
padding: 16,
marginBottom: 24,
background: "#0a0009",
border: `1px solid ${allUp ? "#4ade80" : "#f87171"}`,
color: allUp ? "#4ade80" : "#f87171",
fontWeight: 600,
}}
>
{allUp
? "All systems operational"
: "One or more systems are experiencing issues"}
</div>
{data.maintenance.length > 0 && (
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 18, marginBottom: 12, color: "#d4a5c7" }}>
Scheduled Maintenance
</h2>
{data.maintenance.map((w) => (
<div
key={w.id}
style={{
border: "1px solid #a8577e",
borderRadius: 12,
padding: 12,
marginBottom: 8,
background: "#44275a",
}}
>
<strong style={{ color: "#f5f5f5" }}>{w.title}</strong>
<div style={{ fontSize: 13, marginTop: 4, color: "#d4a5c7" }}>{w.message}</div>
<div style={{ fontSize: 12, color: "#c0c0c0", marginTop: 4 }}>
{new Date(w.startTime).toLocaleString()} {" "}
{new Date(w.endTime).toLocaleString()}
</div>
</div>
))}
</section>
)}
{data.incidents.length > 0 && (
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 18, marginBottom: 12, color: "#d4a5c7" }}>
Active Incidents
</h2>
{data.incidents.map((incident) => (
<div
key={incident.id}
style={{
border: "1px solid #f87171",
borderRadius: 12,
padding: 12,
marginBottom: 8,
background: "#44275a",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
}}
>
<strong style={{ color: "#f5f5f5" }}>{incident.title}</strong>
<span
style={{
fontSize: 12,
textTransform: "uppercase",
color: "#f87171",
}}
>
{incident.status}
</span>
</div>
<div style={{ fontSize: 13, marginTop: 4, color: "#d4a5c7" }}>
{incident.message}
</div>
</div>
))}
</section>
)}
{categories.map((category) => (
<section key={category} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 18, marginBottom: 12, color: "#d4a5c7" }}>
{category}
</h2>
{grouped[category].map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} />
))}
</section>
))}
</>
)}
</div>
);
};
+77
View File
@@ -0,0 +1,77 @@
export type MonitorType = "https" | "https_keyword" | "https_status" | "mongodb" | "port";
export type MonitorStatus = "up" | "down" | "unknown";
export type IncidentStatus =
| "investigating"
| "identified"
| "monitoring"
| "resolved";
export interface MonitorSummary {
category: string;
id: string;
lastChecked: string | null;
message: string | null;
name: string;
responseTimeMs: number | null;
status: MonitorStatus;
statusCode: number | null;
type: MonitorType;
}
export interface Monitor {
allowedStatusCodes: string | null;
category: string;
createdAt: string;
deniedStatusCodes: string | null;
enabled: number;
host: string | null;
id: string;
intervalSeconds: number;
isPublic: number;
keyword: string | null;
name: string;
port: number | null;
type: MonitorType;
url: string | null;
}
export interface MonitorHistory {
checkedAt: string;
id: string;
message: string | null;
monitorId: string;
responseTimeMs: number | null;
status: "up" | "down";
statusCode: number | null;
}
export interface Incident {
createdAt: string;
id: string;
message: string;
status: IncidentStatus;
title: string;
updatedAt: string;
}
export interface MaintenanceWindow {
createdAt: string;
endTime: string;
id: string;
message: string;
startTime: string;
title: string;
}
export interface StatusResponse {
monitors: MonitorSummary[];
incidents: Incident[];
maintenance: MaintenanceWindow[];
}
export interface CurrentUser {
userId: string;
username: string;
globalName: string | null;
avatar: string | null;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
},
},
});
+6
View File
@@ -0,0 +1,6 @@
import nhcarrigan from "@nhcarrigan/eslint-config";
export default [
{ ignores: ["prod/**", "client/**"] },
...nhcarrigan,
];
+41
View File
@@ -0,0 +1,41 @@
{
"name": "oriana",
"version": "1.0.0",
"description": "Custom uptime monitoring tool",
"main": "prod/src/index.js",
"type": "module",
"scripts": {
"dev": "pnpm --prefix client build && op run --env-file=prod.env -- tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "op run --env-file=prod.env -- node prod/src/index.js",
"lint": "eslint --max-warnings 0 .",
"test": "vitest run --coverage"
},
"dependencies": {
"@hono/node-server": "1.19.11",
"better-sqlite3": "12.6.2",
"hono": "4.12.5",
"mongodb": "^7.1.0",
"node-cron": "4.2.1",
"uuid": "13.0.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/better-sqlite3": "7.6.13",
"@types/node": "22.14.0",
"@types/node-cron": "3.0.11",
"@types/uuid": "11.0.0",
"@vitest/coverage-v8": "4.0.18",
"eslint": "9.39.3",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vitest": "4.0.18"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}
}
+4752
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
DISCORD_CLIENT_ID="op://Private/Oriana/discord client id"
DISCORD_CLIENT_SECRET="op://Private/Oriana/discord client secret"
DISCORD_OWNER_ID="op://Private/Oriana/naomi id"
WEBHOOK_URL="op://Private/Oriana/hook"
SESSION_SECRET="op://Private/Oriana/session"
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
const port = 3000;
const discordRedirectUri
= process.env.DISCORD_REDIRECT_URI ?? `http://localhost:${String(port)}/auth/discord/callback`;
export { discordRedirectUri, port };
+37
View File
@@ -0,0 +1,37 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// eslint-disable-next-line @typescript-eslint/naming-convention -- Third-party class uses PascalCase
import BetterSqlite3 from "better-sqlite3";
import { applySchema } from "./schema.js";
let database: BetterSqlite3.Database | null = null;
/**
* Returns the active SQLite database connection.
* @returns The database instance.
* @throws If the database has not been initialised.
*/
const getDatabase = (): BetterSqlite3.Database => {
if (database === null) {
throw new Error(
"Database has not been initialised. Call initialiseDatabase() first.",
);
}
return database;
};
/**
* Opens the SQLite database file and applies the schema.
* @param filePath - Path to the SQLite file. Defaults to oriana.db.
*/
const initialiseDatabase = (filePath = "oriana.db"): void => {
database = new BetterSqlite3(filePath);
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
applySchema(database);
};
export { getDatabase, initialiseDatabase };
+130
View File
@@ -0,0 +1,130 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Database } from "better-sqlite3";
/**
* Recreates the monitors table with an updated CHECK constraint that includes
* all current monitor types. Used when an existing database has an outdated constraint.
* @param database - The SQLite database connection.
*/
const recreateMonitorsTable = (database: Database): void => {
database.exec(`
CREATE TABLE monitors_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('https', 'https_keyword', 'https_status', 'port', 'mongodb')),
url TEXT,
host TEXT,
port INTEGER,
keyword TEXT,
allowed_status_codes TEXT,
denied_status_codes TEXT,
interval_seconds INTEGER NOT NULL DEFAULT 60,
enabled INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT 'General',
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
INSERT INTO monitors_new
SELECT id, name, type, url, host, port, keyword,
allowed_status_codes, denied_status_codes, interval_seconds,
enabled, category, is_public, created_at
FROM monitors;
DROP TABLE monitors;
ALTER TABLE monitors_new RENAME TO monitors;
`);
};
/**
* Applies additive column and constraint migrations for existing databases.
* @param database - The SQLite database connection.
*/
const applyMigrations = (database: Database): void => {
try {
database.exec(
`ALTER TABLE monitors ADD COLUMN category TEXT NOT NULL DEFAULT 'General'`,
);
} catch {
// Column already exists
}
try {
database.exec(
`ALTER TABLE monitors ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1`,
);
} catch {
// Column already exists
}
try {
const schema = database.
prepare<[], { sql: string }>(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'`,
).
get();
if (schema !== undefined && !schema.sql.includes("'mongodb'")) {
recreateMonitorsTable(database);
}
} catch {
// Migration already applied
}
};
/**
* Creates all tables if they do not already exist, then applies migrations.
* @param database - The SQLite database connection.
*/
const applySchema = (database: Database): void => {
database.exec(`
CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('https', 'https_keyword', 'https_status', 'port', 'mongodb')),
url TEXT,
host TEXT,
port INTEGER,
keyword TEXT,
allowed_status_codes TEXT,
denied_status_codes TEXT,
interval_seconds INTEGER NOT NULL DEFAULT 60,
enabled INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT 'General',
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS monitor_history (
id TEXT PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('up', 'down')),
response_time_ms INTEGER,
status_code INTEGER,
message TEXT,
checked_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS maintenance_windows (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
message TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
applyMigrations(database);
};
export { applySchema };
+20
View File
@@ -0,0 +1,20 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { serve } from "@hono/node-server";
import { port } from "./config.js";
import { initialiseDatabase } from "./database/index.js";
import { startEngine } from "./monitors/engine.js";
import { createApp } from "./server.js";
initialiseDatabase();
startEngine();
const app = createApp();
const { fetch } = app;
serve({ fetch, port }, () => {
process.stdout.write(`Oriana is running on http://localhost:${String(port)}\n`);
});
+200
View File
@@ -0,0 +1,200 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { schedule, type ScheduledTask } from "node-cron";
import { v4 as uuidv4 } from "uuid";
import { getDatabase } from "../database/index.js";
import { sendWebhook } from "../notifications/webhook.js";
import { checkHttps, checkHttpsKeyword, checkHttpsStatus } from "./https.js";
import { checkMongoDB } from "./mongodb.js";
import { checkPort } from "./port.js";
import type { CheckResult, Monitor, MonitorHistory } from "../types/index.js";
const activeTasks = new Map<string, ScheduledTask>();
/**
* Fetches the most recent status for a monitor from history.
* @param monitorId - The monitor ID to query.
* @returns The last recorded status string, or null if no history.
*/
const getLatestStatus = (monitorId: string): string | null => {
const database = getDatabase();
const row = database.
prepare<[string], { status: string }>(
`SELECT status
FROM monitor_history
WHERE monitor_id = ?
ORDER BY checked_at DESC
LIMIT 1`,
).
get(monitorId);
return row?.status ?? null;
};
/**
* Writes a check result to the monitor_history table.
* @param monitorId - The ID of the monitor that was checked.
* @param result - The outcome of the check.
* @returns The inserted MonitorHistory record.
*/
const recordResult = (
monitorId: string,
result: CheckResult,
): MonitorHistory => {
const database = getDatabase();
const checkedAt = new Date().toISOString();
const id = uuidv4();
const { message, responseTimeMs, status, statusCode } = result;
const entry: MonitorHistory = {
checkedAt,
id,
message,
monitorId,
responseTimeMs,
status,
statusCode,
};
database.
prepare(
`INSERT INTO monitor_history
(id, monitor_id, status, response_time_ms, status_code, message, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).
run(
entry.id,
entry.monitorId,
entry.status,
entry.responseTimeMs,
entry.statusCode,
entry.message,
entry.checkedAt,
);
return entry;
};
/**
* Selects the appropriate check function for the monitor type.
* @param monitor - The monitor to check.
* @returns A promise resolving to the check result.
*/
const runCheckByType = async(monitor: Monitor): Promise<CheckResult> => {
if (monitor.type === "https") {
return await checkHttps(monitor);
}
if (monitor.type === "https_keyword") {
return await checkHttpsKeyword(monitor);
}
if (monitor.type === "https_status") {
return await checkHttpsStatus(monitor);
}
if (monitor.type === "mongodb") {
return await checkMongoDB(monitor);
}
return await checkPort(monitor);
};
/**
* Runs the appropriate check for a monitor, records the result, and fires a
* webhook notification on status changes.
* @param monitor - The monitor to check.
*/
const runCheck = async(monitor: Monitor): Promise<void> => {
const result = await runCheckByType(monitor);
const previousStatus = getLatestStatus(monitor.id);
recordResult(monitor.id, result);
if (previousStatus !== null && previousStatus !== result.status) {
const webhookMessage = result.status === "up"
? "Monitor recovered."
: result.message ?? "Status changed to down.";
await sendWebhook({
application: monitor.name,
message: webhookMessage,
});
}
};
/**
* Converts an interval in seconds to a cron expression (minimum one minute).
* @param intervalSeconds - The desired check interval in seconds.
* @returns A cron expression string.
*/
const buildCronExpression = (intervalSeconds: number): string => {
const minutes = Math.max(1, Math.ceil(intervalSeconds / 60));
return `*/${String(minutes)} * * * *`;
};
/**
* Schedules (or reschedules) a monitor's periodic check.
* @param monitor - The monitor to schedule.
*/
const scheduleMonitor = (monitor: Monitor): void => {
const existing = activeTasks.get(monitor.id);
if (existing !== undefined) {
void existing.stop();
activeTasks.delete(monitor.id);
}
if (monitor.enabled === 0) {
return;
}
const expression = buildCronExpression(monitor.intervalSeconds);
const task = schedule(expression, () => {
runCheck(monitor).catch(() => {
// Monitoring continues regardless of individual check errors
});
});
activeTasks.set(monitor.id, task);
};
/**
* Removes a monitor's scheduled task.
* @param monitorId - The ID of the monitor to unschedule.
*/
const unscheduleMonitor = (monitorId: string): void => {
const task = activeTasks.get(monitorId);
if (task !== undefined) {
void task.stop();
activeTasks.delete(monitorId);
}
};
/**
* Loads all enabled monitors from the database and starts their scheduled checks.
*/
const startEngine = (): void => {
const database = getDatabase();
const monitors = database.
prepare<[], Monitor>(
`SELECT id, name, type, url, host, port, keyword,
allowed_status_codes AS allowedStatusCodes,
denied_status_codes AS deniedStatusCodes,
interval_seconds AS intervalSeconds,
enabled,
created_at AS createdAt
FROM monitors
WHERE enabled = 1`,
).
all();
for (const monitor of monitors) {
scheduleMonitor(monitor);
}
};
/**
* Stops all active scheduled tasks and clears the task registry.
*/
const stopEngine = (): void => {
for (const task of activeTasks.values()) {
void task.stop();
}
activeTasks.clear();
};
export { scheduleMonitor, startEngine, stopEngine, unscheduleMonitor };
+244
View File
@@ -0,0 +1,244 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { CheckResult, Monitor } from "../types/index.js";
const requestTimeoutMs = 30_000;
/**
* Sends an HTTP GET request and returns the status, body, and response time.
* @param url - The URL to fetch.
* @returns The HTTP status code, response body, and time taken.
*/
const performHttpsRequest = async(
url: string,
): Promise<{ body: string; responseTimeMs: number; status: number }> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, requestTimeoutMs);
const start = Date.now();
try {
const response = await fetch(url, {
method: "GET",
redirect: "follow",
signal: controller.signal,
});
const body = await response.text();
const responseTimeMs = Date.now() - start;
const { status } = response;
return { body, responseTimeMs, status };
} finally {
clearTimeout(timeoutId);
}
};
/**
* Parses a JSON-encoded array of status codes, returning an empty array for null.
* @param json - The JSON string or null.
* @returns An array of status code numbers.
*/
const parseStatusCodes = (json: string | null): Array<number> => {
if (json === null) {
return [];
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External data parsed at runtime
return JSON.parse(json) as Array<number>;
};
/**
* Checks whether an HTTPS endpoint returns a 2xx status code.
* @param monitor - The monitor configuration.
* @returns The check result.
*/
const checkHttps = async(monitor: Monitor): Promise<CheckResult> => {
if (monitor.url === null) {
return {
message: "Monitor has no URL configured.",
responseTimeMs: null,
status: "down",
statusCode: null,
};
}
try {
const result = await performHttpsRequest(monitor.url);
const { status: httpStatus, responseTimeMs } = result;
const isUp = httpStatus >= 200 && httpStatus < 300;
const message = isUp
? null
: `HTTP ${String(httpStatus)}`;
const status = isUp
? "up"
: "down";
const statusCode = httpStatus;
return {
message,
responseTimeMs,
status,
statusCode,
};
} catch (error) {
const message = error instanceof Error
? error.message
: "Unknown error occurred.";
const responseTimeMs = null;
const status = "down";
const statusCode = null;
return {
message,
responseTimeMs,
status,
statusCode,
};
}
};
/**
* Checks whether an HTTPS endpoint response body contains a keyword.
* @param monitor - The monitor configuration.
* @returns The check result.
*/
const checkHttpsKeyword = async(monitor: Monitor): Promise<CheckResult> => {
if (monitor.url === null) {
return {
message: "Monitor has no URL configured.",
responseTimeMs: null,
status: "down",
statusCode: null,
};
}
if (monitor.keyword === null) {
return {
message: "Monitor has no keyword configured.",
responseTimeMs: null,
status: "down",
statusCode: null,
};
}
try {
const result = await performHttpsRequest(monitor.url);
const { body, responseTimeMs, status: httpStatus } = result;
const keywordFound = body.includes(monitor.keyword);
const message = keywordFound
? null
: `Keyword "${monitor.keyword}" not found in response.`;
const status = keywordFound
? "up"
: "down";
const statusCode = httpStatus;
return {
message,
responseTimeMs,
status,
statusCode,
};
} catch (error) {
const message = error instanceof Error
? error.message
: "Unknown error occurred.";
const responseTimeMs = null;
const status = "down";
const statusCode = null;
return {
message,
responseTimeMs,
status,
statusCode,
};
}
};
/**
* Evaluates the HTTP status code against allowed and denied lists.
* @param httpStatus - The HTTP status code returned.
* @param responseTimeMs - The response time in milliseconds.
* @param codes - The allowed and denied status code lists.
* @param codes.allowed - Status codes that indicate up.
* @param codes.denied - Status codes that indicate down.
* @returns The check result.
*/
const evaluateStatusCode = (
httpStatus: number,
responseTimeMs: number,
{ allowed, denied }: { allowed: Array<number>; denied: Array<number> },
): CheckResult => {
const codeString = String(httpStatus);
if (denied.includes(httpStatus)) {
const message = `HTTP ${codeString} is in the denied status codes list.`;
const status = "down";
const statusCode = httpStatus;
return { message, responseTimeMs, status, statusCode };
}
if (allowed.length > 0 && !allowed.includes(httpStatus)) {
const message = `HTTP ${codeString} is not in the allowed status codes list.`;
const status = "down";
const statusCode = httpStatus;
return { message, responseTimeMs, status, statusCode };
}
const message = null;
const status = "up";
const statusCode = httpStatus;
return { message, responseTimeMs, status, statusCode };
};
/**
* Performs the HTTP request and evaluates the status code result.
* @param url - The URL to fetch.
* @param allowed - Status codes that indicate up.
* @param denied - Status codes that indicate down.
* @returns The check result.
*/
const checkHttpsStatusRequest = async(
url: string,
allowed: Array<number>,
denied: Array<number>,
): Promise<CheckResult> => {
try {
const result = await performHttpsRequest(url);
const { responseTimeMs, status: httpStatus } = result;
return evaluateStatusCode(httpStatus, responseTimeMs, { allowed, denied });
} catch (error) {
const message = error instanceof Error
? error.message
: "Unknown error occurred.";
const responseTimeMs = null;
const status = "down";
const statusCode = null;
return {
message,
responseTimeMs,
status,
statusCode,
};
}
};
/**
* Checks whether an HTTPS endpoint returns an allowed (or non-denied) status code.
* @param monitor - The monitor configuration.
* @returns The check result.
*/
const checkHttpsStatus = async(monitor: Monitor): Promise<CheckResult> => {
if (monitor.url === null) {
return {
message: "Monitor has no URL configured.",
responseTimeMs: null,
status: "down",
statusCode: null,
};
}
const allowed = parseStatusCodes(monitor.allowedStatusCodes);
const denied = parseStatusCodes(monitor.deniedStatusCodes);
return await checkHttpsStatusRequest(monitor.url, allowed, denied);
};
export { checkHttps, checkHttpsKeyword, checkHttpsStatus };
+62
View File
@@ -0,0 +1,62 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MongoClient } from "mongodb";
import type { CheckResult, Monitor } from "../types/index.js";
const connectionTimeoutMs = 10_000;
/**
* Opens a MongoDB connection, sends an admin ping, then closes the connection.
* @param connectionString - The MongoDB connection URI.
* @returns The check result.
*/
const performMongoDatabasePing = async(
connectionString: string,
): Promise<CheckResult> => {
const client = new MongoClient(connectionString, {
connectTimeoutMS: connectionTimeoutMs,
serverSelectionTimeoutMS: connectionTimeoutMs,
});
const start = Date.now();
try {
await client.connect();
await client.db("admin").command({ ping: 1 });
const responseTimeMs = Date.now() - start;
const message = null;
const status = "up";
const statusCode = null;
return { message, responseTimeMs, status, statusCode };
} catch (error) {
const message = error instanceof Error
? error.message
: "Unknown error occurred.";
const responseTimeMs = null;
const status = "down";
const statusCode = null;
return { message, responseTimeMs, status, statusCode };
} finally {
await client.close();
}
};
/**
* Checks whether a MongoDB Atlas cluster responds to an admin ping.
* @param monitor - The monitor configuration.
* @returns The check result.
*/
const checkMongoDB = async(monitor: Monitor): Promise<CheckResult> => {
if (monitor.url === null) {
const message = "Monitor has no connection string configured.";
const responseTimeMs = null;
const status = "down";
const statusCode = null;
return { message, responseTimeMs, status, statusCode };
}
return await performMongoDatabasePing(monitor.url);
};
export { checkMongoDB };
+72
View File
@@ -0,0 +1,72 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { createConnection } from "node:net";
import type { CheckResult, Monitor } from "../types/index.js";
const connectionTimeoutMs = 10_000;
/**
* Opens a TCP connection and resolves with the result when it connects,
* times out, or errors.
* @param host - The hostname to connect to.
* @param port - The port to connect to.
* @returns The check result.
*/
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Wraps a callback-based API
const performPortCheck = (host: string, port: number): Promise<CheckResult> => {
return new Promise((resolve) => {
const start = Date.now();
const timeout = connectionTimeoutMs;
const socket = createConnection({ host, port, timeout });
socket.on("connect", () => {
const responseTimeMs = Date.now() - start;
socket.destroy();
const message = null;
const status = "up";
const statusCode = null;
resolve({ message, responseTimeMs, status, statusCode });
});
socket.on("timeout", () => {
socket.destroy();
const message = `Connection timed out after ${String(connectionTimeoutMs)}ms.`;
const responseTimeMs = null;
const status = "down";
const statusCode = null;
resolve({ message, responseTimeMs, status, statusCode });
});
socket.on("error", (error) => {
const { message } = error;
const responseTimeMs = null;
const status = "down";
const statusCode = null;
resolve({ message, responseTimeMs, status, statusCode });
});
});
};
/**
* Checks whether a TCP port is reachable on the configured host.
* @param monitor - The monitor configuration.
* @returns The check result.
*/
const checkPort = async(monitor: Monitor): Promise<CheckResult> => {
if (monitor.host === null || monitor.port === null) {
return {
message: "Monitor has no host or port configured.",
responseTimeMs: null,
status: "down",
statusCode: null,
};
}
return await performPortCheck(monitor.host, monitor.port);
};
export { checkPort };
+31
View File
@@ -0,0 +1,31 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { WebhookPayload } from "../types/index.js";
/**
* POSTs a status-change payload to the configured webhook URL.
* Failures are silently swallowed — monitoring continues regardless.
* @param payload - The data to send.
*/
const sendWebhook = async(payload: WebhookPayload): Promise<void> => {
const webhookUrl = process.env.WEBHOOK_URL;
if (webhookUrl === undefined || webhookUrl === "") {
return;
}
try {
await fetch(webhookUrl, {
body: JSON.stringify(payload),
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard HTTP header name
headers: { "Content-Type": "application/json" },
method: "POST",
});
} catch {
// Webhook delivery failure is non-fatal — monitoring continues regardless
}
};
export { sendWebhook };
+275
View File
@@ -0,0 +1,275 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import { discordRedirectUri } from "../config.js";
const discordApi = "https://discord.com/api/v10";
const sessionCookie = "oriana_session";
// 7 days
const sessionDurationSeconds = 60 * 60 * 24 * 7;
const jwtAlgorithm = "HS256";
interface DiscordConfig {
clientId: string;
clientSecret: string;
ownerId: string;
}
interface DiscordTokenResponse {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API field name
access_token: string;
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API field name
token_type: string;
}
interface DiscordUser {
avatar: string | null;
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API field name
global_name: string | null;
id: string;
username: string;
}
interface SessionPayload {
avatar: string | null;
exp: number;
globalName: string | null;
userId: string;
username: string;
}
/**
* Retrieves and validates the SESSION_SECRET environment variable.
* @returns The session secret string.
* @throws If the secret is not configured.
*/
const getSessionSecret = (): string => {
const secret = process.env.SESSION_SECRET;
if (secret === undefined || secret === "") {
throw new Error("SESSION_SECRET is not set.");
}
return secret;
};
/**
* Reads Discord OAuth environment variables, returning null if any are missing.
* @returns The Discord config object, or null if not configured.
*/
const getDiscordConfig = (): DiscordConfig | null => {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const ownerId = process.env.DISCORD_OWNER_ID;
if (
clientId === undefined
|| clientSecret === undefined
|| ownerId === undefined
) {
return null;
}
return { clientId, clientSecret, ownerId };
};
/**
* Verifies a session JWT and returns the decoded payload if valid.
* @param cookieValue - The raw cookie string from the request.
* @returns The session payload, or null if missing or invalid.
*/
const getSession = async(
cookieValue: string | undefined,
): Promise<SessionPayload | null> => {
if (cookieValue === undefined) {
return null;
}
try {
const raw = await verify(cookieValue, getSessionSecret(), jwtAlgorithm);
const { userId } = raw;
const { username } = raw;
if (typeof userId !== "string" || typeof username !== "string") {
return null;
}
const rawAvatar = raw.avatar;
const rawGlobalName = raw.globalName;
const avatar = typeof rawAvatar === "string"
? rawAvatar
: null;
const globalName = typeof rawGlobalName === "string"
? rawGlobalName
: null;
const exp = raw.exp ?? 0;
return {
avatar,
exp,
globalName,
userId,
username,
};
} catch {
return null;
}
};
/**
* Returns the session payload only if the authenticated user is the owner.
* @param cookieValue - The raw cookie string from the request.
* @returns The session payload for the owner, or null.
*/
const requireAuth = async(
cookieValue: string | undefined,
): Promise<SessionPayload | null> => {
const session = await getSession(cookieValue);
const ownerId = process.env.DISCORD_OWNER_ID;
if (session === null || session.userId !== ownerId) {
return null;
}
return session;
};
/**
* Exchanges a Discord OAuth2 code for an access token.
* @param code - The authorisation code from Discord.
* @param clientId - The Discord application client ID.
* @param clientSecret - The Discord application client secret.
* @returns The access token string, or null on failure.
*/
const exchangeCodeForToken = async(
code: string,
clientId: string,
clientSecret: string,
): Promise<string | null> => {
const tokenResponse = await fetch(`${discordApi}/oauth2/token`, {
body: new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
client_id: clientId,
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
client_secret: clientSecret,
code: code,
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
grant_type: "authorization_code",
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
redirect_uri: discordRedirectUri,
}),
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard HTTP header name
headers: { "Content-Type": "application/x-www-form-urlencoded" },
method: "POST",
});
if (!tokenResponse.ok) {
return null;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response
const data = (await tokenResponse.json()) as DiscordTokenResponse;
return data.access_token;
};
/**
* Fetches the authenticated Discord user's profile.
* @param accessToken - The OAuth2 bearer token.
* @returns The user profile, or null on failure.
*/
const fetchDiscordUser = async(
accessToken: string,
): Promise<DiscordUser | null> => {
const userResponse = await fetch(`${discordApi}/users/@me`, {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard HTTP header name
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userResponse.ok) {
return null;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response
return (await userResponse.json()) as DiscordUser;
};
const authRoutes = new Hono();
authRoutes.get("/discord", (c) => {
const config = getDiscordConfig();
if (config === null) {
return c.json({ error: "Discord OAuth not configured." }, 500);
}
const parameters = new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
client_id: config.clientId,
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
redirect_uri: discordRedirectUri,
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
response_type: "code",
scope: "identify",
});
return c.redirect(
`https://discord.com/oauth2/authorize?${parameters.toString()}`,
);
});
authRoutes.get("/discord/callback", async(c) => {
const code = c.req.query("code");
if (code === undefined) {
return c.json({ error: "No authorisation code provided." }, 400);
}
const config = getDiscordConfig();
if (config === null) {
return c.json({ error: "Discord OAuth not configured." }, 500);
}
const { clientId, clientSecret, ownerId } = config;
const accessToken = await exchangeCodeForToken(code, clientId, clientSecret);
if (accessToken === null) {
return c.json({ error: "Failed to exchange code for token." }, 400);
}
const user = await fetchDiscordUser(accessToken);
if (user === null) {
return c.json({ error: "Failed to fetch Discord user." }, 400);
}
if (user.id !== ownerId) {
return c.redirect("/?error=unauthorised");
}
const exp = Math.floor(Date.now() / 1000) + sessionDurationSeconds;
const { avatar, global_name: globalName, id: userId, username } = user;
const token = await sign(
{ avatar, exp, globalName, userId, username },
getSessionSecret(),
jwtAlgorithm,
);
setCookie(c, sessionCookie, token, {
httpOnly: true,
maxAge: sessionDurationSeconds,
path: "/",
sameSite: "Lax",
});
return c.redirect("/admin");
});
authRoutes.post("/logout", (c) => {
deleteCookie(c, sessionCookie, { path: "/" });
return c.json({ ok: true });
});
authRoutes.get("/me", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
return c.json({
avatar: session.avatar,
globalName: session.globalName,
userId: session.userId,
username: session.username,
});
});
export type { SessionPayload };
export { authRoutes, getSession, requireAuth, sessionCookie };
+277
View File
@@ -0,0 +1,277 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import { v4 as uuidv4 } from "uuid";
import { getDatabase } from "../database/index.js";
import { requireAuth, sessionCookie } from "./auth.js";
import type {
Incident,
IncidentStatus,
MaintenanceWindow,
} from "../types/index.js";
// ── Incidents ──────────────────────────────────────────────────────────────────
const incidentSelect = `
SELECT id, title, message, status,
created_at AS createdAt,
updated_at AS updatedAt
FROM incidents`;
const incidentsRoutes = new Hono();
incidentsRoutes.get("/", (c) => {
const database = getDatabase();
const incidents = database.
prepare<[], Incident>(`${incidentSelect} ORDER BY created_at DESC`).
all();
return c.json(incidents);
});
incidentsRoutes.post("/", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
const body = await c.req.json<{
message: string;
status: IncidentStatus;
title: string;
}>();
const now = new Date().toISOString();
const incident: Incident = {
createdAt: now,
id: uuidv4(),
message: body.message,
status: body.status,
title: body.title,
updatedAt: now,
};
const database = getDatabase();
database.
prepare(
`INSERT INTO incidents (id, title, message, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).
run(
incident.id,
incident.title,
incident.message,
incident.status,
incident.createdAt,
incident.updatedAt,
);
return c.json(incident, 201);
});
incidentsRoutes.put("/:id", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
const { id } = c.req.param();
const database = getDatabase();
const existing = database.
prepare<[string], Incident>(`${incidentSelect} WHERE id = ?`).
get(id);
if (existing === undefined) {
return c.json({ error: "Incident not found." }, 404);
}
const body = await c.req.json<
Partial<{ message: string; status: IncidentStatus; title: string }>
>();
const updated: Incident = {
...existing,
message: body.message ?? existing.message,
status: body.status ?? existing.status,
title: body.title ?? existing.title,
updatedAt: new Date().toISOString(),
};
database.
prepare(
`UPDATE incidents
SET title=?, message=?, status=?, updated_at=?
WHERE id=?`,
).
run(
updated.title,
updated.message,
updated.status,
updated.updatedAt,
updated.id,
);
return c.json(updated);
});
incidentsRoutes.delete("/:id", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
const { id } = c.req.param();
const database = getDatabase();
const existing = database.
prepare<[string], { id: string }>("SELECT id FROM incidents WHERE id = ?").
get(id);
if (existing === undefined) {
return c.json({ error: "Incident not found." }, 404);
}
database.prepare("DELETE FROM incidents WHERE id = ?").run(id);
return c.json({ ok: true });
});
// ── Maintenance Windows ────────────────────────────────────────────────────────
const maintenanceSelect = `
SELECT id, title, message,
start_time AS startTime,
end_time AS endTime,
created_at AS createdAt
FROM maintenance_windows`;
const maintenanceRoutes = new Hono();
maintenanceRoutes.get("/", (c) => {
const database = getDatabase();
const windows = database.
prepare<[], MaintenanceWindow>(
`${maintenanceSelect} ORDER BY start_time ASC`,
).
all();
return c.json(windows);
});
maintenanceRoutes.post("/", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
const body = await c.req.json<{
endTime: string;
message: string;
startTime: string;
title: string;
}>();
const window: MaintenanceWindow = {
createdAt: new Date().toISOString(),
endTime: body.endTime,
id: uuidv4(),
message: body.message,
startTime: body.startTime,
title: body.title,
};
const database = getDatabase();
database.
prepare(
`INSERT INTO maintenance_windows (id, title, message, start_time, end_time, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).
run(
window.id,
window.title,
window.message,
window.startTime,
window.endTime,
window.createdAt,
);
return c.json(window, 201);
});
maintenanceRoutes.put("/:id", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
const { id } = c.req.param();
const database = getDatabase();
const existing = database.
prepare<[string], MaintenanceWindow>(
`${maintenanceSelect} WHERE id = ?`,
).
get(id);
if (existing === undefined) {
return c.json({ error: "Maintenance window not found." }, 404);
}
const body = await c.req.json<
Partial<{
endTime: string;
message: string;
startTime: string;
title: string;
}>
>();
const updated: MaintenanceWindow = {
...existing,
endTime: body.endTime ?? existing.endTime,
message: body.message ?? existing.message,
startTime: body.startTime ?? existing.startTime,
title: body.title ?? existing.title,
};
database.
prepare(
`UPDATE maintenance_windows
SET title=?, message=?, start_time=?, end_time=?
WHERE id=?`,
).
run(
updated.title,
updated.message,
updated.startTime,
updated.endTime,
updated.id,
);
return c.json(updated);
});
maintenanceRoutes.delete("/:id", async(c) => {
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
const { id } = c.req.param();
const database = getDatabase();
const existing = database.
prepare<[string], { id: string }>(
"SELECT id FROM maintenance_windows WHERE id = ?",
).
get(id);
if (existing === undefined) {
return c.json({ error: "Maintenance window not found." }, 404);
}
database.prepare("DELETE FROM maintenance_windows WHERE id = ?").run(id);
return c.json({ ok: true });
});
export { incidentsRoutes, maintenanceRoutes };
+296
View File
@@ -0,0 +1,296 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import { v4 as uuidv4 } from "uuid";
import { getDatabase } from "../database/index.js";
import { scheduleMonitor, unscheduleMonitor } from "../monitors/engine.js";
import { requireAuth, sessionCookie } from "./auth.js";
import type { Monitor, MonitorHistory, MonitorType } from "../types/index.js";
/**
* SELECT clause that applies camelCase aliases for snake_case DB columns.
*/
const monitorSelect = `
SELECT id, name, type, url, host, port, keyword,
allowed_status_codes AS allowedStatusCodes,
denied_status_codes AS deniedStatusCodes,
interval_seconds AS intervalSeconds,
category,
is_public AS isPublic,
enabled,
created_at AS createdAt
FROM monitors`;
type MonitorUpdateBody = Partial<{
allowedStatusCodes: Array<number>;
category: string;
deniedStatusCodes: Array<number>;
enabled: boolean;
host: string;
intervalSeconds: number;
isPublic: boolean;
keyword: string;
name: string;
port: number;
url: string;
}>;
interface MonitorCreateBody {
allowedStatusCodes?: Array<number>;
category?: string;
deniedStatusCodes?: Array<number>;
host?: string;
intervalSeconds?: number;
isPublic?: boolean;
keyword?: string;
name: string;
port?: number;
type: MonitorType;
url?: string;
}
/**
* Serialises a status-code list for DB storage, or returns the existing value when unchanged.
* @param codes - The new codes array, or undefined if not being updated.
* @param existing - The current serialised value.
* @returns The serialised JSON string, or the existing value.
*/
const serializeStatusCodes = (
codes: Array<number> | undefined,
existing: string | null,
): string | null => {
if (codes === undefined) {
return existing;
}
return JSON.stringify(codes);
};
/**
* Merges an existing monitor record with an update body.
* @param existing - The current monitor from the database.
* @param body - The partial update fields from the request.
* @returns A new Monitor object with the updates applied.
*/
const buildUpdatedMonitor = (
existing: Monitor,
body: MonitorUpdateBody,
): Monitor => {
const allowedStatusCodes = serializeStatusCodes(
body.allowedStatusCodes,
existing.allowedStatusCodes,
);
const category = body.category ?? existing.category;
const deniedStatusCodes = serializeStatusCodes(
body.deniedStatusCodes,
existing.deniedStatusCodes,
);
const enabled = body.enabled === undefined
? existing.enabled
: Number(body.enabled);
const host = body.host ?? existing.host;
const intervalSeconds = body.intervalSeconds ?? existing.intervalSeconds;
const isPublic = body.isPublic === undefined
? existing.isPublic
: Number(body.isPublic);
const keyword = body.keyword ?? existing.keyword;
const name = body.name ?? existing.name;
const port = body.port ?? existing.port;
const url = body.url ?? existing.url;
return {
...existing,
allowedStatusCodes,
category,
deniedStatusCodes,
enabled,
host,
intervalSeconds,
isPublic,
keyword,
name,
port,
url,
};
};
/**
* Constructs a new Monitor object from a creation request body.
* @param body - The request body fields.
* @returns The new Monitor object.
*/
const buildNewMonitor = (body: MonitorCreateBody): Monitor => {
const allowedStatusCodes = body.allowedStatusCodes === undefined
? null
: JSON.stringify(body.allowedStatusCodes);
const category = body.category ?? "General";
const deniedStatusCodes = body.deniedStatusCodes === undefined
? null
: JSON.stringify(body.deniedStatusCodes);
const createdAt = new Date().toISOString();
const enabled = 1;
const host = body.host ?? null;
const id = uuidv4();
const intervalSeconds = body.intervalSeconds ?? 60;
const isPublic = body.isPublic === false
? 0
: 1;
const keyword = body.keyword ?? null;
const { name } = body;
const port = body.port ?? null;
const { type } = body;
const url = body.url ?? null;
return {
allowedStatusCodes,
category,
createdAt,
deniedStatusCodes,
enabled,
host,
id,
intervalSeconds,
isPublic,
keyword,
name,
port,
type,
url,
};
};
const monitorsRoutes = new Hono();
monitorsRoutes.use("*", async(c, next) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Hono cookie API
const token = getCookie(c, sessionCookie);
const session = await requireAuth(token);
if (session === null) {
return c.json({ error: "Unauthorised." }, 401);
}
await next();
return c.res;
});
monitorsRoutes.get("/", (c) => {
const database = getDatabase();
const monitors = database.
prepare<[], Monitor>(`${monitorSelect} ORDER BY name ASC`).
all();
return c.json(monitors);
});
monitorsRoutes.post("/", async(c) => {
const body = await c.req.json<MonitorCreateBody>();
const monitor = buildNewMonitor(body);
const database = getDatabase();
database.
prepare(
`INSERT INTO monitors
(id, name, type, url, host, port, keyword,
allowed_status_codes, denied_status_codes, interval_seconds,
category, is_public, enabled, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).
run(
monitor.id,
monitor.name,
monitor.type,
monitor.url,
monitor.host,
monitor.port,
monitor.keyword,
monitor.allowedStatusCodes,
monitor.deniedStatusCodes,
monitor.intervalSeconds,
monitor.category,
monitor.isPublic,
monitor.enabled,
monitor.createdAt,
);
scheduleMonitor(monitor);
return c.json(monitor, 201);
});
monitorsRoutes.put("/:id", async(c) => {
const { id } = c.req.param();
const database = getDatabase();
const existing = database.
prepare<[string], Monitor>(`${monitorSelect} WHERE id = ?`).
get(id);
if (existing === undefined) {
return c.json({ error: "Monitor not found." }, 404);
}
const body = await c.req.json<MonitorUpdateBody>();
const updated = buildUpdatedMonitor(existing, body);
database.
prepare(
`UPDATE monitors
SET name=?, url=?, host=?, port=?, keyword=?,
allowed_status_codes=?, denied_status_codes=?,
interval_seconds=?, category=?, is_public=?, enabled=?
WHERE id=?`,
).
run(
updated.name,
updated.url,
updated.host,
updated.port,
updated.keyword,
updated.allowedStatusCodes,
updated.deniedStatusCodes,
updated.intervalSeconds,
updated.category,
updated.isPublic,
updated.enabled,
updated.id,
);
scheduleMonitor(updated);
return c.json(updated);
});
monitorsRoutes.delete("/:id", (c) => {
const { id } = c.req.param();
const database = getDatabase();
const existing = database.
prepare<[string], { id: string }>("SELECT id FROM monitors WHERE id = ?").
get(id);
if (existing === undefined) {
return c.json({ error: "Monitor not found." }, 404);
}
unscheduleMonitor(id);
database.prepare("DELETE FROM monitors WHERE id = ?").run(id);
return c.json({ ok: true });
});
monitorsRoutes.get("/:id/history", (c) => {
const { id } = c.req.param();
const limitRaw = c.req.query("limit") ?? "100";
const limit = Number(limitRaw);
const database = getDatabase();
const history = database.
prepare<[string, number], MonitorHistory>(
`SELECT id,
monitor_id AS monitorId,
status,
response_time_ms AS responseTimeMs,
status_code AS statusCode,
message,
checked_at AS checkedAt
FROM monitor_history
WHERE monitor_id = ?
ORDER BY checked_at DESC
LIMIT ?`,
).
all(id, limit);
return c.json(history);
});
export { monitorsRoutes };
+162
View File
@@ -0,0 +1,162 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Hono } from "hono";
import { getDatabase } from "../database/index.js";
import type {
Incident,
MaintenanceWindow,
Monitor,
MonitorHistory,
} from "../types/index.js";
/**
* Fetches the most recent history entry for a given monitor.
* @param monitorId - Restricts the query to entries belonging to this monitor.
* @returns The latest history record, or undefined if none exists.
*/
const fetchLatestHistory = (monitorId: string): MonitorHistory | undefined => {
const database = getDatabase();
return database.
prepare<[string], MonitorHistory>(
`SELECT id,
monitor_id AS monitorId,
status,
response_time_ms AS responseTimeMs,
status_code AS statusCode,
message,
checked_at AS checkedAt
FROM monitor_history
WHERE monitor_id = ?
ORDER BY checked_at DESC
LIMIT 1`,
).
get(monitorId);
};
interface HistoryFields {
lastChecked: string | null;
message: string | null;
responseTimeMs: number | null;
status: string;
statusCode: number | null;
}
/**
* Extracts summary fields from a history record, providing defaults when absent.
* @param latest - The latest history record, or undefined if none exists.
* @returns Flat summary fields with null/unknown defaults.
*/
const extractHistoryFields = (
latest: MonitorHistory | undefined,
): HistoryFields => {
if (latest === undefined) {
return {
lastChecked: null,
message: null,
responseTimeMs: null,
status: "unknown",
statusCode: null,
};
}
return {
lastChecked: latest.checkedAt,
message: latest.message,
responseTimeMs: latest.responseTimeMs,
status: latest.status,
statusCode: latest.statusCode,
};
};
/**
* Fetches each enabled, publicly visible monitor's latest status from history.
* @returns An array of monitor summary objects.
*/
interface MonitorSummary extends HistoryFields {
category: string;
id: string;
name: string;
type: string;
}
const fetchMonitorStatuses = (): Array<MonitorSummary> => {
const database = getDatabase();
const monitors = database.
prepare<[], Monitor>(
`SELECT id, name, type, url, host, port, keyword,
allowed_status_codes AS allowedStatusCodes,
denied_status_codes AS deniedStatusCodes,
interval_seconds AS intervalSeconds,
category,
is_public AS isPublic,
enabled,
created_at AS createdAt
FROM monitors
WHERE enabled = 1 AND is_public = 1
ORDER BY name ASC`,
).
all();
return monitors.map((monitor) => {
const fields = extractHistoryFields(fetchLatestHistory(monitor.id));
return {
category: monitor.category,
id: monitor.id,
name: monitor.name,
type: monitor.type,
...fields,
};
});
};
/**
* Fetches all currently active (non-resolved) incidents.
* @returns An array of active incidents.
*/
const fetchActiveIncidents = (): Array<Incident> => {
const database = getDatabase();
return database.
prepare<[], Incident>(
`SELECT id, title, message, status,
created_at AS createdAt,
updated_at AS updatedAt
FROM incidents
WHERE status != 'resolved'
ORDER BY created_at DESC`,
).
all();
};
/**
* Fetches all maintenance windows that overlap the current time.
* @param now - The current ISO timestamp.
* @returns An array of active maintenance windows.
*/
const fetchActiveMaintenance = (now: string): Array<MaintenanceWindow> => {
const database = getDatabase();
return database.
prepare<[string, string], MaintenanceWindow>(
`SELECT id, title, message,
start_time AS startTime,
end_time AS endTime,
created_at AS createdAt
FROM maintenance_windows
WHERE start_time <= ? AND end_time >= ?
ORDER BY start_time ASC`,
).
all(now, now);
};
const statusRoutes = new Hono();
statusRoutes.get("/", (c) => {
const now = new Date().toISOString();
const monitors = fetchMonitorStatuses();
const incidents = fetchActiveIncidents();
const maintenance = fetchActiveMaintenance(now);
return c.json({ incidents, maintenance, monitors });
});
export { statusRoutes };
+41
View File
@@ -0,0 +1,41 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { authRoutes } from "./routes/auth.js";
import { incidentsRoutes, maintenanceRoutes } from "./routes/incidents.js";
import { monitorsRoutes } from "./routes/monitors.js";
import { statusRoutes } from "./routes/status.js";
/**
* Creates and configures the Hono application with all routes and middleware.
* @returns The configured Hono app instance.
*/
const createApp = (): Hono => {
const app = new Hono();
app.use(
"/api/*",
cors({
credentials: true,
origin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173",
}),
);
app.route("/auth", authRoutes);
app.route("/api/status", statusRoutes);
app.route("/api/monitors", monitorsRoutes);
app.route("/api/incidents", incidentsRoutes);
app.route("/api/maintenance", maintenanceRoutes);
app.use("/*", serveStatic({ root: "./client/dist" }));
app.get("*", serveStatic({ path: "./client/dist/index.html" }));
return app;
};
export { createApp };
+89
View File
@@ -0,0 +1,89 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type MonitorType =
| "https"
| "https_keyword"
| "https_status"
| "mongodb"
| "port";
type MonitorStatus = "up" | "down";
type IncidentStatus =
| "investigating"
| "identified"
| "monitoring"
| "resolved";
interface Monitor {
allowedStatusCodes: string | null;
category: string;
createdAt: string;
deniedStatusCodes: string | null;
enabled: number;
host: string | null;
id: string;
intervalSeconds: number;
isPublic: number;
keyword: string | null;
name: string;
port: number | null;
type: MonitorType;
url: string | null;
}
interface MonitorHistory {
checkedAt: string;
id: string;
message: string | null;
monitorId: string;
responseTimeMs: number | null;
status: MonitorStatus;
statusCode: number | null;
}
interface Incident {
createdAt: string;
id: string;
message: string;
status: IncidentStatus;
title: string;
updatedAt: string;
}
interface MaintenanceWindow {
createdAt: string;
endTime: string;
id: string;
message: string;
startTime: string;
title: string;
}
interface CheckResult {
message: string | null;
responseTimeMs: number | null;
status: MonitorStatus;
statusCode: number | null;
}
interface WebhookPayload {
application: string;
message: string;
}
export type {
CheckResult,
Incident,
IncidentStatus,
MaintenanceWindow,
Monitor,
MonitorHistory,
MonitorStatus,
MonitorType,
WebhookPayload,
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./prod",
"rootDir": "."
},
"exclude": ["test/**/*.ts", "client/**/*.ts", "client/**/*.tsx"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/types/**/*.ts"],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
include: ["test/**/*.spec.ts"],
},
});