diff --git a/documentation/04-api.md b/documentation/04-api.md index e4ac0cc..5633deb 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -57,6 +57,8 @@ Vyzaduji `Authorization: Bearer `: | 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` | diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index 494db5d..8d29d18 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -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`, diff --git a/documentation/17-nastaveni-a-prava.md b/documentation/17-nastaveni-a-prava.md index 5b54c59..17c4073 100644 --- a/documentation/17-nastaveni-a-prava.md +++ b/documentation/17-nastaveni-a-prava.md @@ -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` | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 5d6e957..4564ee9 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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` diff --git a/src/data/incidentStore.ts b/src/data/incidentStore.ts index d89bb0d..b07d10e 100644 --- a/src/data/incidentStore.ts +++ b/src/data/incidentStore.ts @@ -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( diff --git a/src/data/permissions.ts b/src/data/permissions.ts index 5387578..241e33a 100644 --- a/src/data/permissions.ts +++ b/src/data/permissions.ts @@ -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' }, diff --git a/src/openapi.ts b/src/openapi.ts index 83f9aec..f638a67 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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'], diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index f724948..a8596de 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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. * diff --git a/web/src/App.tsx b/web/src/App.tsx index a118d2f..87c909a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/pages/dashboard/IncidentDetail.tsx b/web/src/pages/dashboard/IncidentDetail.tsx new file mode 100644 index 0000000..6644dae --- /dev/null +++ b/web/src/pages/dashboard/IncidentDetail.tsx @@ -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 = { + 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(`/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 ( +
+ + + Incidenty + + + + {incident.data && ( +
+
+
+
+
+ {incident.data.id} + + {incident.data.tenantId === null && ( + platformní, týká se všech firem + )} +
+

{incident.data.title}

+

Služba: {incident.data.service}

+
+ +
+ +
+
+
Začátek
+
{formatDateTime(incident.data.startedAt)}
+
+
+
Trvání
+
+ {incident.data.resolvedAt + ? `vyřešeno ${formatRelative(incident.data.resolvedAt)}` + : `běží ${formatRelative(incident.data.startedAt).replace('před ', '')}`} +
+
+
+
Ticket
+
+ {incident.data.ticketId ? ( + + {incident.data.ticketId} + + ) : ( + bez ticketu + )} +
+
+
+
+ +
+

Co to znamená

+

+ {incident.data.impact || 'Bez dalšího popisu.'} +

+ {incident.data.source && ( +

+ Zdroj: {incident.data.source} +

+ )} +
+ + {incident.data.detail && } + + {canManage && ( +
+

Stav

+

+ Posunout do dalšího stavu. Vyřešený incident jde vrátit do sledování, když se + problém ozve znovu. +

+
+ {nextStatuses(incident.data.status).map((status) => ( + + ))} +
+ {change.error &&

{change.error}

} +
+ )} +
+ )} +
+
+ ); +} + +/** + * 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 ( +
+
+

Diagnostika (jen správce platformy)

+ +
+
+        {text}
+      
+
+ ); +} diff --git a/web/src/pages/dashboard/Incidents.tsx b/web/src/pages/dashboard/Incidents.tsx index acc295e..5292c07 100644 --- a/web/src/pages/dashboard/Incidents.tsx +++ b/web/src/pages/dashboard/Incidents.tsx @@ -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() {

Incidenty

- 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.

@@ -37,10 +40,27 @@ export default function Incidents() { {incident.id} -

{incident.title}

+

+ + {incident.title} + +

Služba: {incident.service}

+ {incident.impact &&

{incident.impact}

} + +
+ + + Detail + +
-
@@ -57,9 +77,15 @@ export default function Incidents() {
-
Post-mortem
+
Ticket
- {incident.status === 'resolved' ? 'k dispozici' : 'po vyřešení'} + {incident.ticketId ? ( + + {incident.ticketId} + + ) : ( + bez ticketu + )}