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:
co-authored by
Claude Fable 5.1
parent
bc6508e1f3
commit
42b3da8303
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user