diff --git a/apps/backend/src/admin.rs b/apps/backend/src/admin.rs index 97c2e81..51628c9 100644 --- a/apps/backend/src/admin.rs +++ b/apps/backend/src/admin.rs @@ -5,7 +5,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{delete, get, post}, }; use tower_http::{ services::{ServeDir, ServeFile}, @@ -25,6 +25,7 @@ pub async fn serve( let app = Router::new() .route("/api/admin", get(admin)) .route("/api/incidents", get(list).post(create)) + .route("/api/incidents/{id}", delete(remove)) .route("/api/incidents/{id}/resolve", post(resolve)) .fallback_service(assets) .layer(TraceLayer::new_for_http()) @@ -62,9 +63,20 @@ async fn resolve(State(state): State>, Path(id): Path) -> Re } } +async fn remove(State(state): State>, Path(id): Path) -> Response { + match state.incidents.delete(id).await { + Ok(_) => { + state.publish().await; + StatusCode::NO_CONTENT.into_response() + } + Err(error) => error_response(error), + } +} + fn error_response(error: IncidentError) -> Response { let status = match error { IncidentError::Empty => StatusCode::BAD_REQUEST, + IncidentError::Active => StatusCode::CONFLICT, IncidentError::NotFound => StatusCode::NOT_FOUND, IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR, }; diff --git a/apps/backend/src/incidents.rs b/apps/backend/src/incidents.rs index d82f447..5b8efdc 100644 --- a/apps/backend/src/incidents.rs +++ b/apps/backend/src/incidents.rs @@ -18,6 +18,8 @@ pub enum IncidentError { Empty, #[error("incident not found")] NotFound, + #[error("active incidents cannot be deleted")] + Active, #[error("failed to persist incidents: {0}")] Storage(#[from] std::io::Error), #[error("failed to parse incidents: {0}")] @@ -73,6 +75,20 @@ impl IncidentStore { Ok(resolved) } + pub async fn delete(&self, id: Uuid) -> Result { + let mut incidents = self.incidents.lock().await; + let index = incidents + .iter() + .position(|incident| incident.id == id) + .ok_or(IncidentError::NotFound)?; + if incidents[index].resolved_at.is_none() { + return Err(IncidentError::Active); + } + let deleted = incidents.remove(index); + self.persist(&incidents).await?; + Ok(deleted) + } + async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> { if let Some(parent) = self.path.parent() { tokio::fs::create_dir_all(parent).await?; @@ -125,4 +141,35 @@ mod tests { .await; assert!(matches!(result, Err(IncidentError::Empty))); } + + #[tokio::test] + async fn only_deletes_resolved_incidents() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("incidents.json"); + let store = IncidentStore::load(path.clone()).await.unwrap(); + let created = store + .create(NewIncident { + title: "Service disruption".into(), + message: "Investigating elevated errors.".into(), + severity: Severity::Major, + }) + .await + .unwrap(); + + assert!(matches!( + store.delete(created.id).await, + Err(IncidentError::Active) + )); + store.resolve(created.id).await.unwrap(); + store.delete(created.id).await.unwrap(); + + assert!( + IncidentStore::load(path) + .await + .unwrap() + .list() + .await + .is_empty() + ); + } } diff --git a/apps/frontend/src/components/layout/Header.tsx b/apps/frontend/src/components/layout/Header.tsx index f26eb85..00c68cf 100644 --- a/apps/frontend/src/components/layout/Header.tsx +++ b/apps/frontend/src/components/layout/Header.tsx @@ -42,7 +42,7 @@ export function Header() { className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" render={} > - Report Security Issue + Report security issue
diff --git a/apps/frontend/src/routes/edit.tsx b/apps/frontend/src/routes/edit.tsx index 41cb2a8..ad81f6c 100644 --- a/apps/frontend/src/routes/edit.tsx +++ b/apps/frontend/src/routes/edit.tsx @@ -1,9 +1,27 @@ -import { Button } from "@methanium/ui"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, + cn, +} from "@methanium/ui"; import { createFileRoute, notFound } from "@tanstack/react-router"; -import { CheckCircle2, Loader2, ShieldAlert } from "lucide-react"; -import { type FormEvent, useEffect, useState } from "react"; +import { Check, Loader2, Plus, ShieldAlert, Trash2 } from "lucide-react"; +import { type FormEvent, useEffect, useRef, useState } from "react"; import type { Incident, Severity } from "../status"; +import { formatTime } from "../utils"; export const Route = createFileRoute("/edit")({ beforeLoad: async () => { @@ -17,17 +35,25 @@ export const Route = createFileRoute("/edit")({ component: IncidentEditor, }); +const severityLabels: Record = { + minor: "Minor", + major: "Major", + critical: "Critical", +}; + function IncidentEditor() { const [incidents, setIncidents] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); async function refresh() { try { const response = await fetch("/api/incidents", { cache: "no-store" }); - if (!response.ok) + if (!response.ok) { throw new Error(`Request failed with HTTP ${response.status}`); + } setIncidents((await response.json()) as Incident[]); setError(null); } catch (cause) { @@ -43,16 +69,17 @@ function IncidentEditor() { async function create(event: FormEvent) { event.preventDefault(); + const form = event.currentTarget; + const data = new FormData(form); setSubmitting(true); - const form = new FormData(event.currentTarget); try { const response = await fetch("/api/incidents", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - title: form.get("title"), - message: form.get("message"), - severity: form.get("severity"), + title: data.get("title"), + message: data.get("message"), + severity: data.get("severity"), }), }); if (!response.ok) { @@ -61,7 +88,7 @@ function IncidentEditor() { body.error ?? `Request failed with HTTP ${response.status}`, ); } - event.currentTarget.reset(); + form.reset(); await refresh(); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -75,176 +102,290 @@ function IncidentEditor() { const response = await fetch(`/api/incidents/${id}/resolve`, { method: "POST", }); - if (!response.ok) + if (!response.ok) { throw new Error(`Request failed with HTTP ${response.status}`); + } await refresh(); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } } + async function deleteIncident(id: string) { + setDeletingId(id); + try { + const response = await fetch(`/api/incidents/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(`Request failed with HTTP ${response.status}`); + } + await refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setDeletingId(null); + } + } + const active = incidents.filter((incident) => !incident.resolvedAt); const resolved = incidents .filter((incident) => incident.resolvedAt) - .reverse(); + .toReversed(); return ( -
-
-
-
- -
-
-

- Unprotected administration -

-

- Incident editor -

-

- This interface has no login and should only be exposed on a - trusted network. -

-
+
+
+ +

Incident management

+
+ + {error && ( +
+ {error}
+ )} - {error && ( -
- {error} -
- )} +
+
+

Create incident

-
- -

Create incident

- - - -