Add Downtime Tracker feature for logging application downtime events

New page with full CRUD: application name (combobox with existing values),
start/end datetime, cause, lessons learned. Auto-captures submitting user
and timestamps. All changes tracked in audit log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 14:00:25 -07:00
parent d5818d5341
commit 3b40e4e65e
9 changed files with 921 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
downtime_bp = Blueprint("downtime", __name__, url_prefix="/api/downtime")
from app.downtime import routes # noqa: E402, F401

View File

@@ -0,0 +1,36 @@
from datetime import datetime, timezone
from app.extensions import db
class DowntimeEntry(db.Model):
__tablename__ = "downtime_entries"
id = db.Column(db.Integer, primary_key=True)
application = db.Column(db.String(200), nullable=False)
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=True)
cause = db.Column(db.Text, nullable=True)
lessons_learned = db.Column(db.Text, nullable=True)
submitted_by = db.Column(db.String(200), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self):
return {
"id": self.id,
"application": self.application,
"start_time": self.start_time.isoformat() + "Z" if self.start_time else None,
"end_time": self.end_time.isoformat() + "Z" if self.end_time else None,
"cause": self.cause,
"lessons_learned": self.lessons_learned,
"submitted_by": self.submitted_by,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}

View File

@@ -0,0 +1,192 @@
import logging
from datetime import datetime, timezone
from flask import request, jsonify, session
from app.auth.decorators import login_required
from app.audit import log_audit
from app.extensions import db
from app.downtime import downtime_bp
from app.downtime.models import DowntimeEntry
logger = logging.getLogger(__name__)
@downtime_bp.route("", methods=["GET"])
@login_required
def list_entries():
query = DowntimeEntry.query
search = request.args.get("search", "").strip()
if search:
pattern = f"%{search}%"
query = query.filter(
db.or_(
DowntimeEntry.application.ilike(pattern),
DowntimeEntry.cause.ilike(pattern),
DowntimeEntry.lessons_learned.ilike(pattern),
DowntimeEntry.submitted_by.ilike(pattern),
)
)
app_filter = request.args.get("application", "").strip()
if app_filter:
query = query.filter(DowntimeEntry.application == app_filter)
sort_field = request.args.get("sort", "start_time")
order = request.args.get("order", "desc")
sortable = {
"application": DowntimeEntry.application,
"start_time": DowntimeEntry.start_time,
"end_time": DowntimeEntry.end_time,
"submitted_by": DowntimeEntry.submitted_by,
"created_at": DowntimeEntry.created_at,
"updated_at": DowntimeEntry.updated_at,
}
col = sortable.get(sort_field, DowntimeEntry.start_time)
if order == "desc":
col = col.desc()
query = query.order_by(col)
entries = query.all()
return jsonify({"downtime_entries": [e.to_dict() for e in entries]}), 200
@downtime_bp.route("/applications", methods=["GET"])
@login_required
def list_applications():
"""Return distinct application names for the dropdown."""
rows = db.session.query(DowntimeEntry.application).distinct().order_by(DowntimeEntry.application).all()
return jsonify({"applications": [r[0] for r in rows]}), 200
@downtime_bp.route("", methods=["POST"])
@login_required
def create_entry():
data = request.get_json()
if not data:
return jsonify({"error": "Missing JSON body"}), 400
application = (data.get("application") or "").strip()
start_time_str = data.get("start_time")
if not application:
return jsonify({"error": "Field 'application' is required"}), 400
if not start_time_str:
return jsonify({"error": "Field 'start_time' is required"}), 400
try:
start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return jsonify({"error": "Invalid start_time format"}), 400
end_time = None
end_time_str = data.get("end_time")
if end_time_str:
try:
end_time = datetime.fromisoformat(end_time_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return jsonify({"error": "Invalid end_time format"}), 400
entry = DowntimeEntry(
application=application,
start_time=start_time,
end_time=end_time,
cause=data.get("cause", "").strip() or None,
lessons_learned=data.get("lessons_learned", "").strip() or None,
submitted_by=session.get("user", "unknown"),
)
db.session.add(entry)
db.session.commit()
log_audit("downtime", entry.id, "created", {
"application": entry.application,
"start_time": entry.start_time.isoformat(),
})
return jsonify({"downtime_entry": entry.to_dict()}), 201
@downtime_bp.route("/<int:entry_id>", methods=["GET"])
@login_required
def get_entry(entry_id):
entry = db.session.get(DowntimeEntry, entry_id)
if not entry:
return jsonify({"error": "Downtime entry not found"}), 404
return jsonify({"downtime_entry": entry.to_dict()}), 200
@downtime_bp.route("/<int:entry_id>", methods=["PUT"])
@login_required
def update_entry(entry_id):
entry = db.session.get(DowntimeEntry, entry_id)
if not entry:
return jsonify({"error": "Downtime entry not found"}), 404
data = request.get_json()
if not data:
return jsonify({"error": "Missing JSON body"}), 400
changes = {}
if "application" in data:
new_val = (data["application"] or "").strip()
if not new_val:
return jsonify({"error": "Field 'application' cannot be empty"}), 400
if entry.application != new_val:
changes["application"] = {"old": entry.application, "new": new_val}
entry.application = new_val
if "start_time" in data:
try:
new_start = datetime.fromisoformat(data["start_time"].replace("Z", "+00:00"))
except (ValueError, AttributeError):
return jsonify({"error": "Invalid start_time format"}), 400
old_val = entry.start_time.isoformat() if entry.start_time else None
entry.start_time = new_start
changes["start_time"] = {"old": old_val, "new": new_start.isoformat()}
if "end_time" in data:
if data["end_time"]:
try:
new_end = datetime.fromisoformat(data["end_time"].replace("Z", "+00:00"))
except (ValueError, AttributeError):
return jsonify({"error": "Invalid end_time format"}), 400
else:
new_end = None
old_val = entry.end_time.isoformat() if entry.end_time else None
new_val_str = new_end.isoformat() if new_end else None
if old_val != new_val_str:
changes["end_time"] = {"old": old_val, "new": new_val_str}
entry.end_time = new_end
for field in ("cause", "lessons_learned"):
if field in data:
new_val = (data[field] or "").strip() or None
old_val = getattr(entry, field)
if old_val != new_val:
changes[field] = {"old": old_val, "new": new_val}
setattr(entry, field, new_val)
entry.updated_at = datetime.now(timezone.utc)
db.session.commit()
log_audit("downtime", entry.id, "updated", changes)
return jsonify({"downtime_entry": entry.to_dict()}), 200
@downtime_bp.route("/<int:entry_id>", methods=["DELETE"])
@login_required
def delete_entry(entry_id):
entry = db.session.get(DowntimeEntry, entry_id)
if not entry:
return jsonify({"error": "Downtime entry not found"}), 404
entry_app = entry.application
db.session.delete(entry)
db.session.commit()
log_audit("downtime", entry_id, "deleted", {"application": entry_app})
return jsonify({"message": f"Downtime entry for '{entry_app}' deleted"}), 200

View File

@@ -13,6 +13,7 @@ import LicensesListPage from "@/pages/licenses-list";
import CertsListPage from "@/pages/certs-list";
import UsersListPage from "@/pages/users-list";
import UserDetailPage from "@/pages/user-detail";
import DowntimeListPage from "@/pages/downtime-list";
import AdminPage from "@/pages/admin";
const queryClient = new QueryClient({
@@ -48,6 +49,7 @@ export default function App() {
/>
<Route path="/licenses" element={<LicensesListPage />} />
<Route path="/certs" element={<CertsListPage />} />
<Route path="/downtime" element={<DowntimeListPage />} />
<Route path="/users" element={<UsersListPage />} />
<Route path="/users/:id" element={<UserDetailPage />} />
<Route path="/admin" element={<AdminPage />} />

View File

@@ -0,0 +1,114 @@
import type { ColumnDef } from "@tanstack/react-table";
import { format, parseISO, differenceInMinutes } from "date-fns";
import type { DowntimeEntry } from "@/hooks/use-downtime";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
function formatDuration(startStr: string, endStr: string | null): string {
if (!endStr) return "Ongoing";
const mins = differenceInMinutes(parseISO(endStr), parseISO(startStr));
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const rem = mins % 60;
if (hours < 24) return rem > 0 ? `${hours}h ${rem}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`;
}
export function getDowntimeColumns(): ColumnDef<DowntimeEntry, unknown>[] {
return [
{
accessorKey: "application",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Application" />
),
cell: ({ row }) => (
<span className="font-medium">{row.getValue("application")}</span>
),
filterFn: "arrIncludes",
},
{
accessorKey: "start_time",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Start" />
),
cell: ({ row }) => {
const date = row.getValue("start_time") as string;
return (
<span className="text-sm">
{format(parseISO(date), "MMM d, yyyy HH:mm")}
</span>
);
},
},
{
accessorKey: "end_time",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="End" />
),
cell: ({ row }) => {
const date = row.getValue("end_time") as string | null;
if (!date)
return (
<span className="text-amber-600 dark:text-amber-400 text-sm font-medium">
Ongoing
</span>
);
return (
<span className="text-sm">
{format(parseISO(date), "MMM d, yyyy HH:mm")}
</span>
);
},
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => {
const start = row.original.start_time;
const end = row.original.end_time;
return (
<span className="text-sm text-muted-foreground">
{formatDuration(start, end)}
</span>
);
},
},
{
accessorKey: "cause",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cause" />
),
cell: ({ row }) => {
const cause = row.getValue("cause") as string | null;
if (!cause)
return <span className="text-muted-foreground">-</span>;
return (
<span className="max-w-[200px] truncate text-sm" title={cause}>
{cause}
</span>
);
},
},
{
accessorKey: "submitted_by",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Submitted By" />
),
},
{
accessorKey: "created_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("created_at") as string;
return (
<span className="text-sm text-muted-foreground">
{format(parseISO(date), "MMM d, yyyy")}
</span>
);
},
},
];
}

View File

@@ -0,0 +1,241 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Trash2 } from "lucide-react";
import type { DowntimeEntry } from "@/hooks/use-downtime";
import { useDowntimeApplications } from "@/hooks/use-downtime";
interface DowntimeFormData {
application: string;
start_time: string;
end_time: string;
cause: string;
lessons_learned: string;
}
interface DowntimeFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: {
application: string;
start_time: string;
end_time?: string | null;
cause?: string;
lessons_learned?: string;
}) => void;
initialData?: Partial<DowntimeEntry>;
isLoading?: boolean;
mode?: "create" | "edit";
onDelete?: () => void;
}
function toLocalDatetime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
// Format as YYYY-MM-DDTHH:mm for datetime-local input
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function DowntimeForm({
open,
onOpenChange,
onSubmit,
initialData,
isLoading = false,
mode = "create",
onDelete,
}: DowntimeFormProps) {
const { data: applications } = useDowntimeApplications();
const [formData, setFormData] = useState<DowntimeFormData>({
application: "",
start_time: "",
end_time: "",
cause: "",
lessons_learned: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (open) {
if (initialData && mode === "edit") {
setFormData({
application: initialData.application ?? "",
start_time: toLocalDatetime(initialData.start_time),
end_time: toLocalDatetime(initialData.end_time),
cause: initialData.cause ?? "",
lessons_learned: initialData.lessons_learned ?? "",
});
} else {
setFormData({
application: "",
start_time: "",
end_time: "",
cause: "",
lessons_learned: "",
});
}
setErrors({});
}
}, [open, initialData, mode]);
function validate(): boolean {
const errs: Record<string, string> = {};
if (!formData.application.trim()) errs.application = "Application is required";
if (!formData.start_time) errs.start_time = "Start time is required";
if (formData.start_time && formData.end_time) {
if (new Date(formData.end_time) <= new Date(formData.start_time)) {
errs.end_time = "End time must be after start time";
}
}
setErrors(errs);
return Object.keys(errs).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
onSubmit({
application: formData.application.trim(),
start_time: new Date(formData.start_time).toISOString(),
end_time: formData.end_time
? new Date(formData.end_time).toISOString()
: null,
cause: formData.cause.trim() || undefined,
lessons_learned: formData.lessons_learned.trim() || undefined,
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Log Downtime" : "Edit Downtime Entry"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Record an application downtime event."
: "Update the details of this downtime entry."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="application">Application *</Label>
<Input
id="application"
list="application-list"
value={formData.application}
onChange={(e) =>
setFormData({ ...formData, application: e.target.value })
}
placeholder="Select or type a new application name"
/>
<datalist id="application-list">
{applications?.map((app) => (
<option key={app} value={app} />
))}
</datalist>
{errors.application && (
<p className="text-sm text-destructive">{errors.application}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start_time">Start Time *</Label>
<Input
id="start_time"
type="datetime-local"
value={formData.start_time}
onChange={(e) =>
setFormData({ ...formData, start_time: e.target.value })
}
/>
{errors.start_time && (
<p className="text-sm text-destructive">{errors.start_time}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="end_time">End Time</Label>
<Input
id="end_time"
type="datetime-local"
value={formData.end_time}
onChange={(e) =>
setFormData({ ...formData, end_time: e.target.value })
}
/>
{errors.end_time && (
<p className="text-sm text-destructive">{errors.end_time}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cause">Cause</Label>
<Textarea
id="cause"
value={formData.cause}
onChange={(e) =>
setFormData({ ...formData, cause: e.target.value })
}
placeholder="What caused the downtime?"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lessons_learned">Lessons Learned</Label>
<Textarea
id="lessons_learned"
value={formData.lessons_learned}
onChange={(e) =>
setFormData({ ...formData, lessons_learned: e.target.value })
}
placeholder="What can be done to prevent this in the future?"
rows={3}
/>
</div>
<DialogFooter className="gap-2">
{mode === "edit" && onDelete && (
<Button
type="button"
variant="destructive"
size="sm"
onClick={onDelete}
className="mr-auto"
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="size-4 animate-spin" />}
{mode === "create" ? "Log Downtime" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,6 +5,7 @@ import {
FolderKanban,
KeyRound,
ShieldCheck,
Clock,
Users,
LogOut,
PanelLeftClose,
@@ -29,6 +30,7 @@ const navItems = [
{ to: "/users", icon: Users, label: "Users" },
{ to: "/licenses", icon: KeyRound, label: "Licenses" },
{ to: "/certs", icon: ShieldCheck, label: "Certificates" },
{ to: "/downtime", icon: Clock, label: "Downtime Tracker" },
];
interface SidebarProps {

View File

@@ -0,0 +1,105 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
export interface DowntimeEntry {
id: number;
application: string;
start_time: string;
end_time: string | null;
cause: string | null;
lessons_learned: string | null;
submitted_by: string;
created_at: string;
updated_at: string;
}
interface DowntimeParams {
search?: string;
application?: string;
sort?: string;
order?: "asc" | "desc";
}
export function useDowntimeEntries(params?: DowntimeParams) {
return useQuery({
queryKey: ["downtime", params],
queryFn: async () => {
const res = await api.get("/downtime", { params });
return res.data.downtime_entries as DowntimeEntry[];
},
});
}
export function useDowntimeEntry(id: number | undefined) {
return useQuery({
queryKey: ["downtime", "detail", id],
queryFn: async () => {
const res = await api.get(`/downtime/${id!}`);
return res.data.downtime_entry as DowntimeEntry;
},
enabled: id != null && id > 0,
});
}
export function useDowntimeApplications() {
return useQuery({
queryKey: ["downtime", "applications"],
queryFn: async () => {
const res = await api.get("/downtime/applications");
return res.data.applications as string[];
},
});
}
export function useCreateDowntimeEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: {
application: string;
start_time: string;
end_time?: string | null;
cause?: string;
lessons_learned?: string;
}) => {
const res = await api.post("/downtime", data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["downtime"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useUpdateDowntimeEntry(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<{
application: string;
start_time: string;
end_time: string | null;
cause: string;
lessons_learned: string;
}>) => {
const res = await api.put(`/downtime/${id}`, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["downtime"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDeleteDowntimeEntry(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.delete(`/downtime/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["downtime"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}

View File

@@ -0,0 +1,224 @@
import { useState, useMemo, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import type { ColumnFiltersState } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/shared/data-table";
import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter";
import { getDowntimeColumns } from "@/components/downtime/downtime-columns";
import { DowntimeForm } from "@/components/downtime/downtime-form";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import {
useDowntimeEntries,
useDowntimeEntry,
useCreateDowntimeEntry,
useUpdateDowntimeEntry,
useDeleteDowntimeEntry,
type DowntimeEntry,
} from "@/hooks/use-downtime";
export default function DowntimeListPage() {
const { data: entries, isLoading } = useDowntimeEntries();
const createEntry = useCreateDowntimeEntry();
const [searchParams, setSearchParams] = useSearchParams();
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingEntry, setEditingEntry] = useState<DowntimeEntry | null>(null);
const [deletingEntry, setDeletingEntry] = useState<DowntimeEntry | null>(
null,
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const { data: fullEditingEntry } = useDowntimeEntry(
editingEntry?.id ?? undefined,
);
const updateEntry = useUpdateDowntimeEntry(editingEntry?.id ?? 0);
const deleteEntry = useDeleteDowntimeEntry(deletingEntry?.id ?? 0);
// Derive unique application values for the filter
const applicationOptions = useMemo(() => {
if (!entries) return [];
const unique = [
...new Set(entries.map((e) => e.application).filter(Boolean)),
].sort();
return unique.map((app) => ({ label: app, value: app }));
}, [entries]);
// Read URL params on mount
useEffect(() => {
const editId = searchParams.get("edit");
const appParam = searchParams.get("application");
if (editId && entries) {
const entry = entries.find((e) => e.id === Number(editId));
if (entry) {
setEditingEntry(entry);
const next = new URLSearchParams(searchParams);
next.delete("edit");
setSearchParams(next, { replace: true });
}
}
const filters: ColumnFiltersState = [];
if (appParam) {
filters.push({ id: "application", value: appParam.split(",") });
}
if (filters.length > 0) {
setColumnFilters(filters);
}
}, [searchParams, entries, setSearchParams]);
function handleColumnFiltersChange(filters: ColumnFiltersState) {
setColumnFilters(filters);
const next = new URLSearchParams(searchParams);
next.delete("edit");
const appFilter = filters.find((f) => f.id === "application");
if (appFilter && (appFilter.value as string[]).length > 0) {
next.set("application", (appFilter.value as string[]).join(","));
} else {
next.delete("application");
}
setSearchParams(next, { replace: true });
}
const applicationFilterValue =
(columnFilters.find((f) => f.id === "application")?.value as string[]) ??
[];
const columns = useMemo(() => getDowntimeColumns(), []);
function handleCreate(
data: Parameters<typeof createEntry.mutate>[0],
) {
createEntry.mutate(data, {
onSuccess: () => {
toast.success("Downtime entry logged");
setShowCreateForm(false);
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || "Failed to create downtime entry";
toast.error(msg);
},
});
}
function handleUpdate(
data: Parameters<typeof updateEntry.mutate>[0],
) {
updateEntry.mutate(data, {
onSuccess: () => {
toast.success("Downtime entry updated");
setEditingEntry(null);
},
onError: () => toast.error("Failed to update downtime entry"),
});
}
function handleDelete() {
deleteEntry.mutate(undefined, {
onSuccess: () => {
toast.success("Downtime entry deleted");
setDeletingEntry(null);
},
onError: () => toast.error("Failed to delete downtime entry"),
});
}
function updateFilterForColumn(columnId: string, values: string[]) {
const without = columnFilters.filter((f) => f.id !== columnId);
const next =
values.length > 0
? [...without, { id: columnId, value: values }]
: without;
handleColumnFiltersChange(next);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Downtime Tracker
</h1>
<p className="text-sm text-muted-foreground">
Track and review application downtime events.
</p>
</div>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="size-4" />
Log Downtime
</Button>
</div>
<DataTable
columns={columns}
data={entries ?? []}
isLoading={isLoading}
searchKey="application"
searchPlaceholder="Search downtime entries..."
onRowClick={(entry) => setEditingEntry(entry)}
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
toolbarExtra={
<DataTableFacetedFilter
title="Application"
options={applicationOptions}
selected={applicationFilterValue}
onChange={(values) => updateFilterForColumn("application", values)}
/>
}
emptyTitle="No downtime entries yet"
emptyDescription="Log your first downtime event to start tracking."
emptyAction={{
label: "Log Downtime",
onClick: () => setShowCreateForm(true),
}}
/>
{/* Create form */}
<DowntimeForm
open={showCreateForm}
onOpenChange={setShowCreateForm}
onSubmit={handleCreate}
isLoading={createEntry.isPending}
mode="create"
/>
{/* Edit form */}
{editingEntry && (
<DowntimeForm
open={!!editingEntry}
onOpenChange={(open) => {
if (!open) setEditingEntry(null);
}}
onSubmit={handleUpdate}
initialData={fullEditingEntry ?? editingEntry}
isLoading={updateEntry.isPending}
mode="edit"
onDelete={() => {
setDeletingEntry(editingEntry);
setEditingEntry(null);
}}
/>
)}
{/* Delete confirm */}
<ConfirmDialog
open={!!deletingEntry}
onOpenChange={(open) => {
if (!open) setDeletingEntry(null);
}}
title="Delete Downtime Entry"
description={`Are you sure you want to delete the downtime entry for "${deletingEntry?.application}"? This action cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDelete}
isLoading={deleteEntry.isPending}
/>
</div>
);
}