213 lines
8.3 KiB
TypeScript
213 lines
8.3 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) {
|
|
setErrorMessage('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-2xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-outline-variant/20 pb-4">
|
|
<div>
|
|
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
|
|
Create Operational Incident
|
|
</h1>
|
|
<p className="text-xs text-on-surface-variant mt-0.5">
|
|
Case reference and open status will be generated automatically.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
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">
|
|
<span className="material-symbols-outlined text-[18px]">error</span>
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form Card */}
|
|
<form onSubmit={handleSubmit} className="bg-surface-container-low p-6 rounded-xl border border-outline-variant/30 space-y-5">
|
|
{/* Title */}
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="incident-title" className="block text-xs 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-3.5 py-2 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"
|
|
/>
|
|
</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-bold uppercase 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-3 border-t border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs 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"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs 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"
|
|
>
|
|
{isSubmitting && (
|
|
<span className="material-symbols-outlined text-[16px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<span>{isSubmitting ? 'Creating...' : 'Create Incident'}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|