217 lines
8.7 KiB
TypeScript
217 lines
8.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { IncidentSeverity } from './types';
|
|
|
|
interface OperationsCreateIncidentProps {
|
|
onIncidentCreated: (caseRef: string) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function OperationsCreateIncident({
|
|
onIncidentCreated,
|
|
onCancel,
|
|
}: OperationsCreateIncidentProps) {
|
|
const [title, setTitle] = useState('');
|
|
const [summary, setSummary] = useState('');
|
|
const [severity, setSeverity] = useState<IncidentSeverity>('medium');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!title.trim()) {
|
|
setErrorMessage('Incident title is required.');
|
|
return;
|
|
}
|
|
if (!summary.trim()) {
|
|
setErrorMessage('Incident summary is required.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const response = await fetch('/api/ops/incidents', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
title: title.trim(),
|
|
summary: summary.trim(),
|
|
severity,
|
|
}),
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
setErrorMessage('Your Operations session has expired.');
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
setErrorMessage('You do not have authority to create incidents.');
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
if (response.status === 422) {
|
|
const data = await response.json().catch(() => null);
|
|
setErrorMessage(data?.detail ?? 'Please check the incident fields and try again.');
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => null);
|
|
setErrorMessage(data?.detail ?? 'Operations platform is temporarily unavailable.');
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data?.case_reference) {
|
|
onIncidentCreated(data.case_reference);
|
|
} else {
|
|
onCancel();
|
|
}
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
|
<div>
|
|
<span className="text-xs font-mono text-primary-fixed-dim font-semibold">
|
|
New Incident Record
|
|
</span>
|
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1.5">
|
|
Create Operational Incident
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-on-surface-variant mt-1.5">
|
|
Case reference and open status will be generated automatically.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-surface-container-high hover:bg-surface-bright border border-outline-variant/40 text-xs sm:text-sm font-semibold text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Alert */}
|
|
{errorMessage && (
|
|
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs sm:text-sm">
|
|
<span className="material-symbols-outlined text-[20px]">error</span>
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form Card */}
|
|
<form onSubmit={handleSubmit} className="bg-surface-container-low p-6 sm:p-8 rounded-2xl border border-outline-variant/30 space-y-6 shadow-sm">
|
|
{/* Title */}
|
|
<div className="space-y-2">
|
|
<label htmlFor="incident-title" className="block text-xs sm:text-sm font-semibold text-on-surface">
|
|
Incident Title <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
id="incident-title"
|
|
type="text"
|
|
required
|
|
maxLength={255}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="e.g. Ingress latency spike on edge cluster"
|
|
className="w-full bg-surface-container px-4 py-2.5 rounded-xl border border-outline-variant/40 text-xs sm:text-sm text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Severity Selection */}
|
|
<div className="space-y-1.5">
|
|
<label className="block text-xs font-semibold text-on-surface">
|
|
Initial Severity <span className="text-error">*</span>
|
|
</label>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
|
{(['low', 'medium', 'high', 'critical'] as IncidentSeverity[]).map((sev) => {
|
|
const isSelected = severity === sev;
|
|
return (
|
|
<button
|
|
key={sev}
|
|
type="button"
|
|
onClick={() => setSeverity(sev)}
|
|
className={`px-3 py-2.5 rounded-lg border text-xs font-mono font-semibold capitalize transition-all flex flex-col items-center gap-1 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none ${
|
|
isSelected
|
|
? sev === 'critical'
|
|
? 'bg-error/20 border-error text-error shadow-[0_0_12px_rgba(255,100,100,0.2)]'
|
|
: sev === 'high'
|
|
? 'bg-amber-500/20 border-amber-500 text-amber-300'
|
|
: sev === 'medium'
|
|
? 'bg-primary-container/20 border-primary-container text-primary-container'
|
|
: 'bg-surface-container-high border-outline text-on-surface'
|
|
: 'bg-surface-container border-outline-variant/30 text-outline hover:text-on-surface hover:bg-surface-container-high'
|
|
}`}
|
|
>
|
|
<span>{sev}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="incident-summary" className="block text-xs font-semibold text-on-surface">
|
|
Summary & Symptoms <span className="text-error">*</span>
|
|
</label>
|
|
<span className="text-[10px] font-mono text-outline">
|
|
{summary.length} / 4000
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
id="incident-summary"
|
|
required
|
|
rows={5}
|
|
maxLength={4000}
|
|
value={summary}
|
|
onChange={(e) => setSummary(e.target.value)}
|
|
placeholder="Describe the incident symptoms, initial scope, and observed system behavior..."
|
|
className="w-full bg-surface-container px-3.5 py-2.5 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions Bar */}
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
className="px-5 py-2.5 rounded-xl bg-surface-container border border-outline-variant/30 text-xs sm:text-sm font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-primary-container text-on-primary-container text-xs sm:text-sm font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{isSubmitting && (
|
|
<span className="material-symbols-outlined text-[18px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<span>{isSubmitting ? 'Creating...' : 'Create Incident'}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|