Incident jde otevrit a posunout do dalsiho stavu

Seznam incidentu ukazoval jen titulek a casy, server pritom posilal impact
i diagnostiku. Novy detail /dashboard/incidenty/🆔 co to znamena, ticket,
zdroj, pro spravce platformy cela diagnostika s kopirovanim. Stav jde
posunout (zkoumame, pricina znama, sledujeme, vyreseno), vyreseny vratit
do sledovani. Nove pravo incident.manage, platformni incident meni jen
spravce platformy, zmena jde do auditu. Endpointy GET /incidents/:id
a PATCH /incidents/:id/status, OpenAPI a dokumentace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 13:30:46 +02:00
co-authored by Claude Fable 5.1
parent bc6508e1f3
commit 42b3da8303
11 changed files with 376 additions and 6 deletions
+2
View File
@@ -33,6 +33,7 @@ const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const Helpdesk = lazy(() => import('@/pages/dashboard/Helpdesk'));
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
const IncidentDetail = lazy(() => import('@/pages/dashboard/IncidentDetail'));
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
const Actions = lazy(() => import('@/pages/dashboard/Actions'));
const ActionDetail = lazy(() => import('@/pages/dashboard/ActionDetail'));
@@ -88,6 +89,7 @@ export default function App() {
<Route path="helpdesk" element={<Helpdesk />} />
<Route path="tickety/:id" element={<TicketDetail />} />
<Route path="incidenty" element={<Incidents />} />
<Route path="incidenty/:id" element={<IncidentDetail />} />
<Route path="nastaveni" element={<Settings />} />
<Route path="akce" element={<Actions />} />
<Route path="akce/:id" element={<ActionDetail />} />
+204
View File
@@ -0,0 +1,204 @@
import { ArrowLeft, Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { IncidentSeverityBadge, IncidentStatusBadge } from '@/components/dashboard/StatusBadge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { useAccess } from '@/lib/collections';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import type { Incident, IncidentStatus } from '@/types/dashboard';
/**
* Detail incidentu.
*
* Dve vrstvy, jak je ma i zaznam: `impact` je pro klienta (co to pro nej
* znamena), `detail` je diagnostika pro spravce platformy - server ho
* ostatnim posila jako null, takze se tady jen nekresli.
*
* Stav se posouva dopredu po krocich (zkoumame, pricina znama, sledujeme,
* vyreseno). Vyreseny incident jde vratit do "sledujeme", kdyz se problem
* ozve znovu - bez toho by musel vzniknout novy a historie by se roztrhla.
*/
const STATUS_FLOW: IncidentStatus[] = ['investigating', 'identified', 'monitoring', 'resolved'];
const STATUS_LABEL: Record<IncidentStatus, string> = {
investigating: 'Zkoumáme',
identified: 'Příčina známa',
monitoring: 'Sledujeme',
resolved: 'Vyřešeno',
};
/** Kam se da z daneho stavu posunout. */
function nextStatuses(current: IncidentStatus): IncidentStatus[] {
if (current === 'resolved') return ['monitoring'];
const index = STATUS_FLOW.indexOf(current);
return STATUS_FLOW.slice(index + 1);
}
export default function IncidentDetail() {
const { id = '' } = useParams();
const { user } = useAuth();
const access = useAccess();
const incident = useApiQuery<Incident>(`/api/dashboard/incidents/${id}`, {
refetchOn: ['incident.updated', 'incident.resolved'],
});
usePageMeta({ title: incident.data ? `Incident ${incident.data.id}` : 'Incident', area: 'portal' });
const canManage =
user?.platformAdmin === true ||
(incident.data?.tenantId !== null && (access.data?.permissions ?? []).includes('incident.manage'));
const change = useSubmit(async (status: IncidentStatus) => {
await apiFetch(`/api/dashboard/incidents/${id}/status`, { method: 'PATCH', body: { status } });
incident.reload();
}, 'Stav se nepodařilo změnit.');
return (
<div className="space-y-6">
<Link
to="/dashboard/incidenty"
className="inline-flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
>
<ArrowLeft className="size-4" />
Incidenty
</Link>
<DataState
loading={incident.loading}
refreshing={incident.refreshing}
error={incident.error}
onRetry={incident.reload}
>
{incident.data && (
<div className="space-y-6">
<header className="glass rounded-card p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
<span className="font-mono text-xs text-white/40">{incident.data.id}</span>
<IncidentSeverityBadge severity={incident.data.severity} />
{incident.data.tenantId === null && (
<span className="text-xs text-white/40">platformní, týká se všech firem</span>
)}
</div>
<h1 className="mt-2 text-2xl font-bold text-white">{incident.data.title}</h1>
<p className="mt-1 text-sm text-white/50">Služba: {incident.data.service}</p>
</div>
<IncidentStatusBadge status={incident.data.status} />
</div>
<dl className="mt-5 grid gap-4 border-t border-ink-600/50 pt-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-xs text-white/35">Začátek</dt>
<dd className="mt-0.5 text-white/70">{formatDateTime(incident.data.startedAt)}</dd>
</div>
<div>
<dt className="text-xs text-white/35">Trvání</dt>
<dd className="mt-0.5 text-white/70">
{incident.data.resolvedAt
? `vyřešeno ${formatRelative(incident.data.resolvedAt)}`
: `běží ${formatRelative(incident.data.startedAt).replace('před ', '')}`}
</dd>
</div>
<div>
<dt className="text-xs text-white/35">Ticket</dt>
<dd className="mt-0.5 text-white/70">
{incident.data.ticketId ? (
<Link
to={`/dashboard/tickety/${incident.data.ticketId}`}
className="font-mono hover:underline"
>
{incident.data.ticketId}
</Link>
) : (
<span className="text-white/35">bez ticketu</span>
)}
</dd>
</div>
</dl>
</header>
<section className="glass rounded-card p-6">
<h2 className="text-sm font-semibold text-white">Co to znamená</h2>
<p className="mt-2 text-sm leading-relaxed text-white/75">
{incident.data.impact || 'Bez dalšího popisu.'}
</p>
{incident.data.source && (
<p className="mt-3 text-xs text-white/40">
Zdroj: <span className="font-mono">{incident.data.source}</span>
</p>
)}
</section>
{incident.data.detail && <Diagnostics text={incident.data.detail} />}
{canManage && (
<section className="glass rounded-card p-6">
<h2 className="text-sm font-semibold text-white">Stav</h2>
<p className="mt-1 text-xs text-white/45">
Posunout do dalšího stavu. Vyřešený incident jde vrátit do sledování, když se
problém ozve znovu.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{nextStatuses(incident.data.status).map((status) => (
<Button
key={status}
size="sm"
variant={status === 'resolved' ? 'primary' : 'secondary'}
disabled={change.busy}
onClick={() => void change.submit(status)}
>
{STATUS_LABEL[status]}
</Button>
))}
</div>
{change.error && <p className="mt-2 text-sm text-red-300">{change.error}</p>}
</section>
)}
</div>
)}
</DataState>
</div>
);
}
/**
* Diagnostika pro spravce platformy: cele hlaseni vcetne ID behu a dat na
* vstupu, nic se nezkracuje. Tlacitko kopirovani, protoze prave tohle se
* posila dal tomu, kdo to bude opravovat.
*/
function Diagnostics({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.warn('[incident] kopirovani selhalo:', err);
}
}
return (
<section className="glass rounded-card p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-white">Diagnostika (jen správce platformy)</h2>
<Button size="sm" variant="ghost" onClick={() => void copy()}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
{copied ? 'Zkopírováno' : 'Kopírovat'}
</Button>
</div>
<pre className="mt-3 max-h-[32rem] overflow-auto rounded-lg bg-ink-950 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-white/80">
{text}
</pre>
</section>
);
}
+31 -5
View File
@@ -1,3 +1,5 @@
import { ChevronRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { IncidentSeverityBadge, IncidentStatusBadge } from '@/components/dashboard/StatusBadge';
import { formatDateTime, formatRelative } from '@/lib/format';
@@ -17,7 +19,8 @@ export default function Incidents() {
<header>
<h1 className="text-2xl font-bold text-white">Incidenty</h1>
<p className="mt-1 text-sm text-white/50">
Výpadky a degradace služeb. Timeline zásahů a post-mortem doplníme v další iteraci.
Výpadky a chyby automatizací. Detail říká, co to pro vás znamená, a správce ho posune
do dalšího stavu.
</p>
</header>
@@ -37,10 +40,27 @@ export default function Incidents() {
<span className="font-mono text-xs text-white/40">{incident.id}</span>
<IncidentSeverityBadge severity={incident.severity} />
</div>
<h2 className="mt-2 font-semibold text-white">{incident.title}</h2>
<h2 className="mt-2 font-semibold text-white">
<Link
to={`/dashboard/incidenty/${incident.id}`}
className="hover:text-brand-300 hover:underline"
>
{incident.title}
</Link>
</h2>
<p className="mt-1 text-sm text-white/50">Služba: {incident.service}</p>
{incident.impact && <p className="mt-2 text-sm text-white/70">{incident.impact}</p>}
</div>
<div className="flex flex-col items-end gap-2">
<IncidentStatusBadge status={incident.status} />
<Link
to={`/dashboard/incidenty/${incident.id}`}
className="inline-flex items-center gap-1 text-xs text-white/50 hover:text-white"
>
Detail
<ChevronRight className="size-3.5" />
</Link>
</div>
<IncidentStatusBadge status={incident.status} />
</div>
<dl className="mt-5 grid gap-4 border-t border-ink-600/50 pt-4 text-sm sm:grid-cols-3">
@@ -57,9 +77,15 @@ export default function Incidents() {
</dd>
</div>
<div>
<dt className="text-xs text-white/35">Post-mortem</dt>
<dt className="text-xs text-white/35">Ticket</dt>
<dd className="mt-0.5 text-white/70">
{incident.status === 'resolved' ? 'k dispozici' : 'po vyřešení'}
{incident.ticketId ? (
<Link to={`/dashboard/tickety/${incident.ticketId}`} className="font-mono hover:underline">
{incident.ticketId}
</Link>
) : (
<span className="text-white/35">bez ticketu</span>
)}
</dd>
</div>
</dl>