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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5a124a53d8
commit
ab88979627
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string> | null;
|
||||
}
|
||||
|
||||
/** Kolik zaznamu o overeni se u konektoru drzi. */
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+39
-3
@@ -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<string, string> {
|
||||
const picked: Record<string, string> = {};
|
||||
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<string, string>,
|
||||
): 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 };
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
constructor(
|
||||
kind: ScriptErrorKind,
|
||||
@@ -193,6 +201,7 @@ export class ScriptError extends Error {
|
||||
status?: number;
|
||||
detail?: string;
|
||||
request?: ScriptRequestInfo;
|
||||
responseHeaders?: Record<string, string>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> | 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 (
|
||||
<div
|
||||
@@ -92,7 +106,7 @@ export function ErrorDetail({
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
{detail && (
|
||||
{(detail || headerLines.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((previous) => !previous)}
|
||||
@@ -113,6 +127,14 @@ export function ErrorDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && headerLines.length > 0 && (
|
||||
<div className="mt-2 max-h-40 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs break-words text-white/50">
|
||||
{headerLines.map((line) => (
|
||||
<div key={line}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && detail && (
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap break-words text-white/70">
|
||||
{detail}
|
||||
|
||||
@@ -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(
|
||||
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
|
||||
<div
|
||||
className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm"
|
||||
@@ -85,6 +92,7 @@ export function Modal({
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<p className="border-b border-ink-600/60 px-5 py-3 font-mono text-xs break-all text-white/40">
|
||||
@@ -442,6 +444,7 @@ function ConnectorLogs({
|
||||
detail={check.detail ?? undefined}
|
||||
status={check.status ?? undefined}
|
||||
request={check.request ?? undefined}
|
||||
responseHeaders={check.responseHeaders}
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<string, string> | 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<string, string>;
|
||||
}
|
||||
|
||||
export interface ServiceCatalog {
|
||||
|
||||
Reference in New Issue
Block a user