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
@@ -57,6 +57,8 @@ Vyzaduji `Authorization: Bearer <token>`:
| GET | `/api/invites/:kod` |
| POST | `/api/invites/:kod/accept` |
| GET | `/api/dashboard/incidents` |
| GET | `/api/dashboard/incidents/:id` |
| PATCH | `/api/dashboard/incidents/:id/status` |
| GET | `/api/dashboard/storage` |
| GET | `/api/dashboard/services` |
| GET | `/api/dashboard/connectors/services` |
+1
View File
@@ -226,6 +226,7 @@ a drzi se vsude, kde se neco zaklada:
| pozvanka | `user.manage`, role jen z te firmy | `src/routes/invites.ts` |
| konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard.ts` |
| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard.ts` |
| stav incidentu | `incident.manage` za firmu incidentu, platformni jen spravce platformy | `src/routes/dashboard.ts` |
| akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.ts` |
Spravce firmy s `user.manage` ma **jen svou firmu**: nenastavi `platformAdmin`,
+1
View File
@@ -55,6 +55,7 @@ byla, ale `viewer` mohl zalozit konektor nebo smazat automatizaci. Ted:
| pozvanky | `user.manage`, role jen z te firmy |
| konektory (zalozeni, uprava, smazani, test) | `connector.manage` |
| automatizace (zalozeni, uprava, smazani, novy token) | `automation.edit` |
| stav incidentu | `incident.manage` za firmu incidentu; platformni jen spravce platformy |
| vestavene akce na ticketu | pravo akce za firmu ticketu plus strop viditelnosti |
| prepnuti na jiny ucet, audit | `impersonate`, `audit.view` |
+18
View File
@@ -2,6 +2,24 @@
Nejnovejsi nahore.
## 2026-09-09 - Incident jde otevrit a posunout do dalsiho stavu
Seznam incidentu ukazoval jen titulek, sluzbu a casy. Server pritom uz posilal
`impact` (co to pro klienta znamena) a spravci platformy `detail`
(diagnostiku z behu), ale nic z toho se nekreslilo a nebylo kam kliknout.
Stav incidentu slo menit jen v kodu.
- `GET /api/dashboard/incidents/:id` a stranka `/dashboard/incidenty/:id`:
co to znamena, odkaz na ticket, zdroj (ID behu), a pro spravce platformy
cela diagnostika s tlacitkem kopirovat. Cizi incident je 404.
- `PATCH /api/dashboard/incidents/:id/status`: posun dopredu (zkoumame,
pricina znama, sledujeme, vyreseno); vyreseny jde vratit do sledovani, kdyz
se problem ozve znovu. Nove pravo `incident.manage` (spravce firmy ho ma
automaticky pres `syncSystemRoles`); platformni incident bez firmy meni jen
spravce platformy. Zmena jde do auditu jako `incident.status`.
- Seznam ma odkaz na detail, ukazuje `impact` a ticket; sloupec "Post-mortem",
ktery nic nedelal, je pryc.
## 2026-09-09 - Prepinani jazyku docasne schovane
Do rozhodnuti o nove znacce je web jen cesky. `MULTILANG_ENABLED = false`
+6
View File
@@ -124,6 +124,12 @@ export function listIncidents(tenantIds: string[]): Incident[] {
});
}
/** Incident podle ID. Cizi firmy se chova jako neexistujici, stejne jako u ticketu. */
export function findIncident(id: string, tenantIds: string[]): Incident | undefined {
const incident = incidents.find((item) => item.id === id);
return incident && visibleTo(incident, tenantIds) ? incident : undefined;
}
/** Uz je stejny problem otevreny? Aby deset stejnych chyb nedelalo deset incidentu. */
export function findOpenIncident(source: string, tenantIds: string[]): Incident | undefined {
return incidents.find(
+6
View File
@@ -68,6 +68,12 @@ export const permissionCatalog: PermissionDefinition[] = [
},
{ key: 'action.manage', label: 'Spravovat definice akcí', group: 'Nastavení firmy' },
{ key: 'automation.edit', label: 'Upravovat automatizace', group: 'Nastavení firmy' },
{
key: 'incident.manage',
label: 'Měnit stav incidentu',
group: 'Nastavení firmy',
hint: 'Posouvat incident firmy do dalšího stavu až po vyřešení. Platformní incidenty mění jen správce platformy.',
},
{ key: 'connector.manage', label: 'Spravovat konektory', group: 'Nastavení firmy' },
{ key: 'widget.manage', label: 'Spravovat vlastní widgety', group: 'Nastavení firmy' },
{ key: 'group.manage', label: 'Spravovat skupiny řešitelů', group: 'Nastavení firmy' },
+42
View File
@@ -2274,6 +2274,48 @@ export function buildOpenApiDocument() {
},
},
},
'/api/dashboard/incidents/{id}': {
get: {
tags: ['Dashboard'],
summary: 'Detail incidentu',
description:
'Incident firmy nebo platformni. `detail` (diagnostika) dostane jen spravce platformy, ' +
'ostatni maji null. Cizi incident je 404.',
security: [{ bearerAuth: [] }],
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }),
'404': { description: 'Incident neexistuje nebo patri jine firme' },
},
},
},
'/api/dashboard/incidents/{id}/status': {
patch: {
tags: ['Dashboard'],
summary: 'Posunout incident do dalsiho stavu',
description:
'Pravo incident.manage za firmu incidentu; platformni incident (bez firmy) meni jen ' +
'spravce platformy. Stav resolved nastavi resolvedAt.',
security: [{ bearerAuth: [] }],
parameters: [idParam, tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['status'],
properties: {
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
},
}),
responses: {
'200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }),
'400': { description: 'Neznamy stav' },
'403': { description: 'Chybi pravo incident.manage' },
'404': { description: 'Incident neexistuje nebo patri jine firme' },
},
},
},
'/api/dashboard/stream': {
get: {
tags: ['Dashboard'],
+63 -1
View File
@@ -39,7 +39,13 @@ import {
} from '../data/dashboardLayouts.js';
import { collectScopes } from '../data/flowScope.js';
import { widgetCatalog } from '../data/widgets.js';
import { createIncident, listIncidents } from '../data/incidentStore.js';
import {
createIncident,
findIncident,
listIncidents,
updateIncidentStatus,
type Incident,
} from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { isMember, listGroups, listPeople } from '../data/people.js';
import { hasPermission } from '../data/permissions.js';
@@ -145,6 +151,62 @@ dashboardRouter.get('/incidents', (req, res) => {
});
});
/** Stejne pravidlo jako u seznamu: diagnostiku vidi jen spravce platformy. */
function publicIncident(req: Request, incident: Incident): Incident {
return { ...incident, detail: req.user!.platformAdmin ? incident.detail : null };
}
dashboardRouter.get('/incidents/:id', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const incident = findIncident(req.params.id, scope.tenantIds);
if (!incident) return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
return res.json(publicIncident(req, incident));
});
const incidentStatusSchema = z.object({
status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']),
});
/**
* Posun incidentu do dalsiho stavu.
*
* Incident firmy meni, kdo ma v te firme `incident.manage`. Platformni incident
* (bez firmy) je nas a meni ho jen spravce platformy - klient by jinak mohl
* "vyresit" vypadek, ktery se tyka vsech.
*/
dashboardRouter.patch('/incidents/:id/status', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const incident = findIncident(req.params.id, scope.tenantIds);
if (!incident) return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
const allowed =
req.user!.platformAdmin ||
(incident.tenantId !== null && hasPermission(req.user!, 'incident.manage', incident.tenantId));
if (!allowed) {
return res.status(403).json({ error: 'forbidden', message: 'Stav incidentu nemůžete měnit.' });
}
const parsed = incidentStatusSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const updated = updateIncidentStatus(incident.id, parsed.data.status);
if (!updated) return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: incident.tenantId,
action: 'incident.status',
target: incident.id,
detail: { status: parsed.data.status },
});
return res.json(publicIncident(req, updated));
});
/**
* Hlaseni padu portalu.
*
+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>