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:
5
backend/app/downtime/__init__.py
Normal file
5
backend/app/downtime/__init__.py
Normal 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
|
||||
36
backend/app/downtime/models.py
Normal file
36
backend/app/downtime/models.py
Normal 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,
|
||||
}
|
||||
192
backend/app/downtime/routes.py
Normal file
192
backend/app/downtime/routes.py
Normal 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
|
||||
@@ -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 />} />
|
||||
|
||||
114
frontend/src/components/downtime/downtime-columns.tsx
Normal file
114
frontend/src/components/downtime/downtime-columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
241
frontend/src/components/downtime/downtime-form.tsx
Normal file
241
frontend/src/components/downtime/downtime-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
105
frontend/src/hooks/use-downtime.ts
Normal file
105
frontend/src/hooks/use-downtime.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
224
frontend/src/pages/downtime-list.tsx
Normal file
224
frontend/src/pages/downtime-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user