generated from nhcarrigan/template
feat: initial implementation of Oriana uptime monitor
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 50s
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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1437
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user