From ab88979627f6eb7781d02f7c270aa70d4236cbeb Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:15:22 +0200 Subject: [PATCH] Dialog portalem do body, hlavicky odpovedi u chyby Dialog se vykresloval uvnitr karty konektoru misto pres obrazovku. Samo position: fixed nestaci: rodic s backdrop-filter (nase .glass, tedy skoro kazdy panel a karta) je pro fixed potomka containing block. Modal proto jde portalem do document.body. Tykalo se to vsech dialogu, videt to bylo az u Logu, ktere jsou v male karte. K chybe se zapisuji vybrane hlavicky odpovedi: server, via, content-type, www-authenticate, retry-after, x-request-id, date. Rikaji, kdo odpoved vydal. Server: Kestrel je sama aplikace, Via: 1.1 Caddy proxy pred ni. U 403 od proxy byva telo prazdne a bez hlavicek by nezbylo vubec nic. Allowlist, ne vsechno: Set-Cookie a podobne do zaznamu nepatri. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/12-sluzby-a-konektory.md | 7 ++++ documentation/99-zmeny.md | 23 ++++++++++- src/data/connectors/types.ts | 5 +++ src/openapi.ts | 11 +++++ src/routes/connectors.ts | 7 ++++ src/scripts/http.ts | 42 ++++++++++++++++++-- src/scripts/types.ts | 10 +++++ web/src/components/dashboard/ErrorDetail.tsx | 28 +++++++++++-- web/src/components/ui/Modal.tsx | 12 +++++- web/src/pages/dashboard/Connectors.tsx | 3 ++ web/src/types/dashboard.ts | 4 ++ 11 files changed, 143 insertions(+), 9 deletions(-) diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index 4e0fc4c..00ced8c 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -179,6 +179,13 @@ Samotny kod odpovedi nikoho nikam nedovede. Hlaska proto nese tri veci: na karte konektoru i v hlavicce dialogu Logy. Hadat ji podle toho, kde je nasazeny portal, nejde. +4. **Vybrane hlavicky odpovedi.** `server`, `via`, `content-type`, + `www-authenticate`, `retry-after`, `x-request-id`, `date`. Rikaji, **kdo** + odpoved vydal: `Server: Kestrel` je sama aplikace, `Via: 1.1 Caddy` proxy + pred ni. U 403 od proxy byva telo prazdne, takze tohle je jedina stopa, + ktera zbyde. Allowlist, ne vsechno - `Set-Cookie` a podobne do zaznamu + nepatri. + Co se **nedela**: sonda na `/health` vedle overeni. `/health` povoleni IP adresy nevyzaduje, takze z toho, ze projde, se o IP nic nedozvis - byla by to veta, ktera zni jako zaver a zadny neni. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 9b2e18b..6108c4a 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -40,13 +40,34 @@ nepomuze. Duvod pritom sluzba do tela odpovedi napsala, jen se zahodil. Migrace `003_connector_checks.sql`, endpoint `GET /api/dashboard/connectors/:id/checks`. +### Opraveno + +- **Dialog se vykresluje portalem do `document.body`.** Samo `position: fixed` + nestaci: rodic s `backdrop-filter` (nase `.glass`, tedy skoro kazdy panel + a karta) je pro fixed potomka containing block. Dialog se pak vesel do te + karty misto pres celou obrazovku. Tykalo se to vsech dialogu, jen to bylo + videt az u Logu, ktere jsou v male karte konektoru. + +### Pridano pozdeji + +- **Vybrane hlavicky odpovedi u chyby** (`ScriptError.responseHeaders`): + `server`, `via`, `content-type`, `www-authenticate`, `retry-after`, + `x-request-id`, `date`. Rikaji, kdo odpoved vydal - `Server: Kestrel` je + aplikace, `Via: 1.1 Caddy` proxy pred ni. U 403 od proxy byva telo prazdne + a bez hlavicek by nezbylo nic. Allowlist, ne vsechno: `Set-Cookie` a podobne + do zaznamu nepatri. + ### Nedoreseno Proc iDoklad vraci 403, zatim nevime. Vylouceno je to, co posilame: zadna kombinace hlavicek (`Idempotency-Key`, `Accept`, User-Agent) 403 nevyvola, sluzba na ne odpovida 401 jako na cokoliv jineho. Zbyva **zdrojova IP adresa naseho containeru** nebo **403 od iDokladu samotneho**, ktere sluzba jen -predava dal. Rozhodne to telo te odpovedi, ktere je nove v portalu pod Logy. +predava dal. Zvenci to reprodukovat nejde: pres dvacet variant hlavicek +(`Idempotency-Key`, `Accept`, User-Agent, jazyk, delka a tvar udaju, duplicitni +hlavicky, HTTP/1.1) vraci vzdycky 401. To ale nic nedokazuje o IP adrese +containeru - z povolene IP se pochopitelne projde. Rozhodnou hlavicky odpovedi +a jeji telo, obojí je nove v portalu pod Logy. Sonda na `/health` vedle overeni byla spatny napad a je pryc: `/health` povoleni IP adresy nevyzaduje, takze z toho, ze projde, o IP nic neplyne. diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 063227e..7157f71 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -30,6 +30,11 @@ export interface ConnectorCheck { detail: string | null; /** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */ request: { method: string; path: string; url: string } | null; + /** + * Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo + * proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde. + */ + responseHeaders: Record | null; } /** Kolik zaznamu o overeni se u konektoru drzi. */ diff --git a/src/openapi.ts b/src/openapi.ts index 17e626a..e2cdeef 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1739,6 +1739,17 @@ export function buildOpenApiDocument() { url: { type: 'string' }, }, }, + responseHeaders: { + type: 'object', + nullable: true, + additionalProperties: { type: 'string' }, + description: + 'Vybrane hlavicky odpovedi (server, via, content-type, ' + + 'www-authenticate, retry-after, x-request-id, date). Rikaji, kdo ' + + 'odpoved vydal - aplikace, nebo proxy pred ni. U kodu bez tela ' + + 'je to jedina stopa, ktera zbyde. Allowlist, ne vsechno: ' + + 'Set-Cookie a podobne do zaznamu nepatri.', + }, }, }, }, diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index a78e022..12d76ee 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -289,6 +289,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { message, detail: null, request: null, + responseHeaders: null, }, [tenantId], ); @@ -334,6 +335,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { message, detail: null, request, + responseHeaders: null, }, [tenantId], ); @@ -359,6 +361,9 @@ connectorsRouter.post('/:id/test', async (req, res) => { const request = isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl }; + // Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno, + // co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy. + const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null; const check: ConnectorCheck = { at: new Date().toISOString(), ok: false, @@ -367,6 +372,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { message, detail: detail ?? null, request, + responseHeaders, }; await setConnectorStatus(connector.id, 'error', message, check, [tenantId]); @@ -380,6 +386,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { ...(status !== undefined ? { status } : {}), request, baseUrl: target.baseUrl, + ...(responseHeaders ? { responseHeaders } : {}), ...(detail ? { detail } : {}), }); } finally { diff --git a/src/scripts/http.ts b/src/scripts/http.ts index 7fd0512..bbc7e68 100644 --- a/src/scripts/http.ts +++ b/src/scripts/http.ts @@ -102,6 +102,33 @@ const reasonListKeys = ['missingHeaders', 'errors', 'Errors']; /** Strop na duvod v hlasce. Cele telo zustava v `detail`, tohle je jen veta. */ const reasonBytes = 400; +/** + * Hlavicky odpovedi, ktere se zapisuji k chybe. + * + * Allowlist, ne vsechno: v odpovedi muze byt `Set-Cookie` nebo token a ten + * do zaznamu nepatri. Tyhle rikaji, **kdo** odpoved vydal - `Server: Kestrel` + * je sama aplikace, `Via: 1.1 Caddy` proxy pred ni. U 403 bez tela je to + * jedina stopa, ktera zbyde. + */ +const keptResponseHeaders = [ + 'server', + 'via', + 'content-type', + 'www-authenticate', + 'retry-after', + 'x-request-id', + 'date', +]; + +function pickResponseHeaders(response: Response): Record { + const picked: Record = {}; + for (const name of keptResponseHeaders) { + const value = response.headers.get(name); + if (value !== null && value !== '') picked[name] = truncate(value, 200); + } + return picked; +} + function reasonFromText(value: string, depth: number): string | null { const raw = value.trim(); if (raw === '') return null; @@ -183,13 +210,17 @@ function statusError( status: number, request: ScriptRequestInfo, detail: string, + responseHeaders: Record, ): ScriptError { const where = `${request.method} ${request.url} vrátilo HTTP ${status}`; const reason = reasonFromBody(detail); + // Kdyz telo nic nerika, aspon se rekne, kdo odpoved vydal. Prazdne telo + // u 403 obvykle znamena, ze to nevydala aplikace, ale neco pred ni. + const who = responseHeaders.server ?? responseHeaders.via; const said = reason ? ` Služba odpověděla: ${reason}` - : ' Služba k tomu nenapsala nic, tělo odpovědi je prázdné.'; - const options = { status, detail, request }; + : ` Tělo odpovědi je prázdné${who ? `, odpověď vydal ${who}` : ''}.`; + const options = { status, detail, request, responseHeaders }; if (retryableStatuses.has(status)) { return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.${said}`, options); @@ -339,7 +370,12 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { if (!response.ok && !allowed.includes(response.status)) { // Zamerne surove telo, ne prochazene pres JSON.stringify - u chyby chceme // presne to, co sluzba poslala, vcetne pripadneho HTML nebo textu. - throw statusError(response.status, request, redact(truncate(raw, config.errorDetailBytes))); + throw statusError( + response.status, + request, + redact(truncate(raw, config.errorDetailBytes)), + pickResponseHeaders(response), + ); } return { status: response.status, body: parsed as T }; diff --git a/src/scripts/types.ts b/src/scripts/types.ts index 133dc79..65234ed 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -185,6 +185,14 @@ export class ScriptError extends Error { */ readonly detail?: string; readonly request?: ScriptRequestInfo; + /** + * Vybrane hlavicky odpovedi. Rikaji, **kdo** odpoved vydal: `Server: Kestrel` + * je aplikace, `Via: 1.1 Caddy` sama proxy. U kodu bez tela (a 403 od proxy + * telo casto nema) je to jedina stopa, ktera zbyde. + * + * Allowlist, ne vsechno: `Set-Cookie` a podobne do zaznamu nepatri. + */ + readonly responseHeaders?: Record; constructor( kind: ScriptErrorKind, @@ -193,6 +201,7 @@ export class ScriptError extends Error { status?: number; detail?: string; request?: ScriptRequestInfo; + responseHeaders?: Record; cause?: unknown; } = {}, ) { @@ -202,6 +211,7 @@ export class ScriptError extends Error { this.status = options.status; this.detail = options.detail; this.request = options.request; + this.responseHeaders = options.responseHeaders; } } diff --git a/web/src/components/dashboard/ErrorDetail.tsx b/web/src/components/dashboard/ErrorDetail.tsx index d96c158..432c6a5 100644 --- a/web/src/components/dashboard/ErrorDetail.tsx +++ b/web/src/components/dashboard/ErrorDetail.tsx @@ -16,6 +16,7 @@ export function ErrorDetail({ detail, status, request, + responseHeaders, retryable, kind, defaultOpen = false, @@ -27,6 +28,11 @@ export function ErrorDetail({ status?: number; /** `url` je cela adresa vcetne serveru. Bez ni nejde poznat, kam to slo. */ request?: { method: string; path: string; url?: string }; + /** + * Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo + * proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde. + */ + responseHeaders?: Record | null; retryable?: boolean; kind?: string; /** @@ -39,6 +45,10 @@ export function ErrorDetail({ const [open, setOpen] = useState(defaultOpen); const [copied, setCopied] = useState(false); + const headerLines = Object.entries(responseHeaders ?? {}).map( + ([name, value]) => `${name}: ${value}`, + ); + /** Co se zkopiruje: vsechno, co o chybe vime, at to jde poslat dal. */ const fullText = [ message, @@ -48,9 +58,12 @@ export function ErrorDetail({ retryable !== undefined ? `Opakování: ${retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}` : null, + // Hlavicky rikaji, kdo odpoved vydal. Do schranky patri s tim ostatnim, + // protoze prave tohle se posila dal, kdyz to ma nekdo resit. + ...(headerLines.length > 0 ? ['', 'Hlavičky odpovědi:', ...headerLines] : []), detail ? `\nOdpověď služby:\n${detail}` : null, ] - .filter(Boolean) + .filter((line) => line !== null) .join('\n'); async function copy() { @@ -64,7 +77,8 @@ export function ErrorDetail({ } } - const hasMore = Boolean(detail) || request !== undefined || status !== undefined; + const hasMore = + Boolean(detail) || headerLines.length > 0 || request !== undefined || status !== undefined; return (
- {detail && ( + {(detail || headerLines.length > 0) && (
)} + {open && headerLines.length > 0 && ( +
+ {headerLines.map((line) => ( +
{line}
+ ))} +
+ )} + {open && detail && (
           {detail}
diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx
index e56dc70..1ef4053 100644
--- a/web/src/components/ui/Modal.tsx
+++ b/web/src/components/ui/Modal.tsx
@@ -1,11 +1,18 @@
 import { X } from 'lucide-react';
 import { useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
 import type { ReactNode } from 'react';
 import { cn } from '@/lib/cn';
 
 /**
  * Dialog nad obsahem. Zavira se Esc, klikem na pozadi i krizkem.
  * Pri otevreni zamkne scroll stranky a preda focus dovnitr.
+ *
+ * **Vykresluje se portalem do `document.body`, ne na miste volani.** Samo
+ * `position: fixed` nestaci: rodic s `backdrop-filter` (nase `.glass`, tedy
+ * skoro kazdy panel a karta) je pro fixed potomka containing block a dialog
+ * se pak vejde do te karty misto pres celou obrazovku. Bez portalu je tedy
+ * dialog v karte konektoru maly a nepouzitelny.
  */
 export function Modal({
   open,
@@ -50,7 +57,7 @@ export function Modal({
 
   if (!open) return null;
 
-  return (
+  return createPortal(
     
-
+
, + document.body, ); } diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 76fccb3..6a4896d 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -318,6 +318,7 @@ function ConnectorCard({ detail={test.detail} status={test.status} request={test.request} + responseHeaders={test.responseHeaders} defaultOpen /> )} @@ -399,6 +400,7 @@ function ConnectorLogs({ onClose={onClose} title={`Logy: ${connector.name}`} description="Posledních pět ověření. U neúspěchu je vidět celá odpověď služby." + className="max-w-4xl" > {targetUrl && (

@@ -442,6 +444,7 @@ function ConnectorLogs({ detail={check.detail ?? undefined} status={check.status ?? undefined} request={check.request ?? undefined} + responseHeaders={check.responseHeaders} defaultOpen /> )} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 93d1d2d..af27061 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -395,6 +395,8 @@ export interface ConnectorCheck { detail: string | null; /** Cela adresa vcetne serveru, bez query. */ request: { method: string; path: string; url: string } | null; + /** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */ + responseHeaders: Record | null; } export interface ConnectorTestResult { @@ -409,6 +411,8 @@ export interface ConnectorTestResult { detail?: string; /** Zaklad adresy, na kterou konektor miri. Vraci se i kdyz overeni projde. */ baseUrl?: string; + /** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */ + responseHeaders?: Record; } export interface ServiceCatalog {