diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index 0db2917..4e0fc4c 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -155,6 +155,56 @@ nahlas - test, ktery projde i se spatnymi udaji, by uzivateli rikal nepravdu. Neuspesne overeni **neni chyba API**. Vraci se 200 s `ok: false` a popisem, protoze vysledek "nefunguje to" je platna odpoved na otazku "funguje to?". +### Co ma stat v hlasce + +Samotny kod odpovedi nikoho nikam nedovede. Hlaska proto nese tri veci: + +1. **Duvod, ktery napsala sama sluzba.** Vytahne se z tela odpovedi + (`detail`, `error_description`, `message`, `title`, `error`, seznam + `missingHeaders`). Kdyz sluzba vlozi do `detail` cele JSON tela od te + skutecne sluzby za ni, rozbali se to dal. Kdyz sluzba nenapsala nic, + rekne se i to - prazdne telo je taky informace. +2. **Rozlisene 401 a 403.** Kazdy znamena neco jineho a rada za nim je opacna: + 401 = sluzba udaje dostala a neuznala je, jde tedy o Client ID, Client Secret + nebo jejich platnost. 403 = tvar udaju v poradku, zakazuje se samo volani, + tedy nepovolena IP adresa, chybejici opravneni uctu nebo aplikace, + pod kterou se vola. +3. **Celou adresu vcetne serveru**, ne jen cestu. Zaklad adresy je + ze `SERVICES_BASE_URL` a konektor ho smi prepsat, takze + "`/apps/idoklad/account/agenda` vratilo 403" nerika, jestli se to vubec + trefilo na spravny stroj, nebo to zaridla cizi proxy cestou. Query se + do hlasky nedava, muze v ni byt tajemstvi. + + Adresa se vraci i **pri uspechu** (`baseUrl` v odpovedi na test) a je videt + na karte konektoru i v hlavicce dialogu Logy. Hadat ji podle toho, kde je + nasazeny portal, nejde. + +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. + +Cele telo odpovedi zustava v `detail` a v portalu je u overeni konektoru +**rozbalene rovnou**. Slozeny toggle by u chyby, kterou nikdo necekal, jen +schoval to jedine, co ji vysvetluje. + +### Historie overeni + +Kazde overeni se ulozi ke konektoru, drzi se **poslednich pet** zaznamu +(`CHECK_HISTORY`). Zaznam nese cas, jestli proslo, co se overovalo, HTTP kod, +hlasku, cele telo odpovedi a ktere volani to bylo. + +Uklada se i uspesne overeni. Bez nej se neda poznat, jestli konektor nesel +nikdy, nebo prestal jit ve chvili, kdy nekdo sahnul na udaje. + +V portalu je to pod tlacitkem **Logy** na karte konektoru. Do te doby existovala +odpoved sluzby jen v odpovedi na test, tedy do prekresleni stranky, a v logu +containeru. Do logu containeru se nikdo divat nechodi, takze to bylo totez +jako nikde. + +Historie se **nevraci v seznamu konektoru**, jen `checkCount`. Pet tel odpovedi +na konektor jsou desitky kilobajtu za neco, co vetsinu casu nikdo necte. +Cte se zvlast pres `/connectors/:id/checks`. + ## API | Metoda | Cesta | Popis | @@ -167,6 +217,7 @@ protoze vysledek "nefunguje to" je platna odpoved na otazku "funguje to?". | PATCH | `/api/dashboard/connectors/:id` | upravit | | DELETE | `/api/dashboard/connectors/:id` | smazat | | POST | `/api/dashboard/connectors/:id/test` | overit napojeni | +| GET | `/api/dashboard/connectors/:id/checks` | poslednich pet overeni | ## Stranky portalu diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 8b2d065..9b2e18b 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,55 @@ Nejnovejsi nahore. +## 2026-08-25 - chybova hlaseni konektoru rikaji, co se stalo + +"Pristup zamitnut: GET /apps/idoklad/account/agenda vratilo HTTP 403. +Zkontrolujte pristupove udaje." Tahle hlaska je k nicemu. 403 muze byt +nepovolena IP adresa i spatne udaje a veta radi presne to, co v tu chvili +nepomuze. Duvod pritom sluzba do tela odpovedi napsala, jen se zahodil. + +### Zmeneno + +- **Duvod od sluzby jde primo do hlasky.** `src/scripts/http.ts` vytahne + z tela odpovedi `detail`, `error_description`, `message`, `title`, `error` + i seznam `missingHeaders`. Retezec, ktery vypada jako JSON, se rozbaluje + dal - nase sluzba iDoklad presne takhle predava telo od iDokladu samotneho. + Kdyz sluzba nenapsala nic, hlaska to rekne, misto aby to zamlcela. +- **401 a 403 uz nejsou jedna hlaska.** 401 = udaje sluzba dostala a neuznala, + IP adresa s tim nema co delat. 403 = tvar udaju je v poradku, zakazuje se + samo volani, tedy IP adresa, opravneni uctu nebo aplikace, pod kterou se vola. +- **Cela odpoved sluzby je u overeni konektoru rozbalena rovnou** + (`ErrorDetail` ma novy `defaultOpen`). U chyby, kterou nikdo necekal, + je slozeny toggle to same jako zadny detail. + +### Pridano + +- **Cela adresa vcetne serveru v kazde hlasce.** `ScriptRequestInfo` ma nove + `url` (origin a cesta, bez query - v query muze byt tajemstvi). Do te doby + hlaska rikala jen `/apps/idoklad/account/agenda`, coz nerekne, jestli se to + trefilo na spravny stroj, nebo to zaridla cizi proxy cestou. Zaklad adresy + je z konfigurace a konektor ho smi prepsat, takze se neda odvodit z toho, + kde je nasazeny portal. Adresa je videt i na karte konektoru, v hlavicce + dialogu Logy a v odpovedi na test i kdyz projde (`baseUrl`). +- **Tlacitko Logy na karte konektoru a historie poslednich peti overeni.** + Dosud odpoved sluzby existovala jen v odpovedi na test, tedy do prekresleni + stranky, a v logu containeru. Do logu containeru se nikdo divat nechodi. + Zaznam se uklada i pri uspechu, jinak by neslo poznat, jestli konektor nesel + nikdy, nebo prestal jit ve chvili, kdy nekdo sahnul na udaje. + Migrace `003_connector_checks.sql`, endpoint + `GET /api/dashboard/connectors/:id/checks`. + +### 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. + +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. + ## 2026-08-20 - vystup z vetve plati i za podminkou Pri stavbe cesty "objednavka -> faktura" vyslo najevo, ze se bezny postup neda diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index 77e62ec..6bc756a 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -25,6 +25,7 @@ import { createLocalConnectors } from './connectors/local.js'; import { postgresConnectors } from './connectors/postgres.js'; import type { Connector, + ConnectorCheck, ConnectorRepository, CreateConnectorInput, UpdateConnectorInput, @@ -33,12 +34,13 @@ import { fileSnapshot, memorySnapshot } from './snapshot.js'; export type { Connector, + ConnectorCheck, ConnectorIssue, CreateConnectorInput, PublicConnector, UpdateConnectorInput, } from './connectors/types.js'; -export { toPublicConnector, validateConnectorValues } from './connectors/types.js'; +export { CHECK_HISTORY, toPublicConnector, validateConnectorValues } from './connectors/types.js'; export type StorageMode = 'postgres' | 'file' | 'memory'; @@ -226,7 +228,8 @@ export function setConnectorStatus( id: string, status: Connector['status'], error: string | null, + check: ConnectorCheck | null, tenantIds: string[], ): Promise { - return repository.setStatus(id, status, error, tenantIds); + return repository.setStatus(id, status, error, check, tenantIds); } diff --git a/src/data/connectors/local.ts b/src/data/connectors/local.ts index 5021ec3..45d5d6b 100644 --- a/src/data/connectors/local.ts +++ b/src/data/connectors/local.ts @@ -19,6 +19,7 @@ import { openAll, sealAll } from '../../db/secretBox.js'; import { findService } from '../services.js'; import type { SnapshotStore } from '../snapshot.js'; import { + CHECK_HISTORY, nowIso, type Connector, type ConnectorRepository, @@ -73,7 +74,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto /** Kopie, aby volajici nemohl zmenit stav uloziste zapisem do vysledku. */ function copy(row: Connector): Connector { - return { ...row, values: { ...row.values } }; + return { ...row, values: { ...row.values }, checks: [...row.checks] }; } return { @@ -84,7 +85,9 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto rows.length = 0; for (const item of stored) { const { secrets, ...rest } = item; - rows.push({ ...rest, values: openAll(secrets) }); + // Soubor zapsany starsi verzi historii overeni nema. Chybejici pole + // je prazdna historie, ne duvod, proc by uloziste nemelo nastartovat. + rows.push({ ...rest, values: openAll(secrets), checks: rest.checks ?? [] }); } await seed(); }, @@ -140,6 +143,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto status: 'untested', lastCheckAt: null, lastError: null, + checks: [], // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. isDefault: input.isDefault ?? existing.length === 0, createdAt: timestamp, @@ -168,6 +172,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto // Zmena udaju znamena, ze predchozi overeni uz nic nerika. row.status = 'untested'; row.lastError = null; + row.checks = []; } if (patch.isDefault === true) { @@ -199,12 +204,13 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto return true; }, - async setStatus(id, status, error, tenantIds) { + async setStatus(id, status, error, check, tenantIds) { const row = rows.find((item) => item.id === id); if (!row || !tenantIds.includes(row.tenantId)) return undefined; row.status = status; row.lastError = error; + if (check) row.checks = [check, ...row.checks].slice(0, CHECK_HISTORY); row.lastCheckAt = nowIso(); row.updatedAt = row.lastCheckAt; persist(); @@ -235,6 +241,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto status: 'untested', lastCheckAt: null, lastError: null, + checks: [], isDefault: true, createdAt: timestamp, updatedAt: timestamp, diff --git a/src/data/connectors/postgres.ts b/src/data/connectors/postgres.ts index dbbfe87..cacc018 100644 --- a/src/data/connectors/postgres.ts +++ b/src/data/connectors/postgres.ts @@ -13,8 +13,10 @@ import { randomUUID } from 'node:crypto'; import { query, queryOne, transaction } from '../../db/pool.js'; import { openAll, sealAll } from '../../db/secretBox.js'; +import { CHECK_HISTORY } from './types.js'; import type { Connector, + ConnectorCheck, ConnectorRepository, CreateConnectorInput, UpdateConnectorInput, @@ -31,6 +33,7 @@ interface ConnectorRow { status: string; last_check_at: Date | null; last_error: string | null; + checks: unknown; is_default: boolean; created_at: Date; updated_at: Date; @@ -48,6 +51,7 @@ function toConnector(row: ConnectorRow): Connector { status: row.status as Connector['status'], lastCheckAt: row.last_check_at ? row.last_check_at.toISOString() : null, lastError: row.last_error, + checks: Array.isArray(row.checks) ? (row.checks as ConnectorCheck[]) : [], isDefault: row.is_default, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), @@ -56,7 +60,7 @@ function toConnector(row: ConnectorRow): Connector { const COLUMNS = ` id, tenant_id, service_id, name, base_url, secrets, enabled, status, - last_check_at, last_error, is_default, created_at, updated_at + last_check_at, last_error, checks, is_default, created_at, updated_at `; export const postgresConnectors: ConnectorRepository = { @@ -187,6 +191,7 @@ export const postgresConnectors: ConnectorRepository = { is_default = CASE WHEN $7::boolean THEN true ELSE is_default END, status = CASE WHEN $8::boolean THEN 'untested' ELSE status END, last_error = CASE WHEN $8::boolean THEN NULL ELSE last_error END, + checks = CASE WHEN $8::boolean THEN '[]'::jsonb ELSE checks END, updated_at = now() WHERE id = $1 RETURNING ${COLUMNS}`, @@ -234,14 +239,28 @@ export const postgresConnectors: ConnectorRepository = { }); }, - async setStatus(id, status, error, tenantIds) { + /** + * Zapis vysledku overeni. + * + * Historie se skrouhne az v jednom kroku v SQL, ne ctenim a zapisem zvlast: + * dve soubezna overeni tehoz konektoru by si jinak navzajem prepsala + * zaznam a jeden by zmizel. + */ + async setStatus(id, status, error, check, tenantIds) { if (tenantIds.length === 0) return undefined; const row = await queryOne( `UPDATE connectors - SET status = $3, last_error = $4, last_check_at = now(), updated_at = now() + SET status = $3, + last_error = $4, + checks = CASE + WHEN $5::jsonb IS NULL THEN checks + ELSE jsonb_path_query_array($5::jsonb || checks, '$[0 to ${CHECK_HISTORY - 1}]') + END, + last_check_at = now(), + updated_at = now() WHERE id = $1 AND tenant_id = ANY($2) - RETURNING ${COLUMNS}`, - [id, tenantIds, status, error], + RETURNING ${'${COLUMNS}'}`, + [id, tenantIds, status, error, check ? JSON.stringify([check]) : null], ); return row ? toConnector(row) : undefined; }, diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 086519a..063227e 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -10,6 +10,31 @@ import { findService, type Service, type ServiceCredentialField } from '../services.js'; +/** + * Jeden zaznam o overeni konektoru. + * + * Drzi se **cele telo odpovedi**, ne jen kod. Prave tam sluzba pise, co ji + * vadilo, a bez toho se neda rozlisit spatny udaj od zakazane IP adresy. + * Text je zredigovany uz pri vzniku, takze pristupovy udaj v nem nikdy neni. + */ +export interface ConnectorCheck { + /** ISO cas overeni. */ + at: string; + ok: boolean; + /** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */ + checked: string; + /** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */ + status: number | null; + message: string; + /** Cele telo odpovedi sluzby. */ + detail: string | null; + /** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */ + request: { method: string; path: string; url: string } | null; +} + +/** Kolik zaznamu o overeni se u konektoru drzi. */ +export const CHECK_HISTORY = 5; + export interface Connector { id: string; tenantId: string; @@ -25,6 +50,8 @@ export interface Connector { status: 'untested' | 'ok' | 'error'; lastCheckAt: string | null; lastError: string | null; + /** Poslednich `CHECK_HISTORY` overeni, nejnovejsi prvni. */ + checks: ConnectorCheck[]; /** Krok stromu bez vybraneho konektoru pouzije vychozi. */ isDefault: boolean; createdAt: string; @@ -45,6 +72,12 @@ export interface PublicConnector { status: Connector['status']; lastCheckAt: string | null; lastError: string | null; + /** + * Kolik zaznamu o overeni je k dispozici. Samotna historie se sem nedava, + * v seznamu konektoru by to byly desitky kilobajtu tel odpovedi navic. + * Cte se zvlast pres `/connectors/:id/checks`. + */ + checkCount: number; isDefault: boolean; createdAt: string; updatedAt: string; @@ -102,6 +135,8 @@ export interface ConnectorRepository { id: string, status: Connector['status'], error: string | null, + /** Zaznam, ktery se zaradi na zacatek historie. null = historii nemenit. */ + check: ConnectorCheck | null, tenantIds: string[], ): Promise; } @@ -142,8 +177,15 @@ export function toPublicConnector(connector: Connector): PublicConnector { const missing = service ? missingFields(service, connector.values) : []; - const { values: _values, ...rest } = connector; - return { ...rest, filled, missing, config, ready: missing.length === 0 }; + const { values: _values, checks, ...rest } = connector; + return { + ...rest, + checkCount: checks.length, + filled, + missing, + config, + ready: missing.length === 0, + }; } /** diff --git a/src/db/migrations/003_connector_checks.sql b/src/db/migrations/003_connector_checks.sql new file mode 100644 index 0000000..c9cabce --- /dev/null +++ b/src/db/migrations/003_connector_checks.sql @@ -0,0 +1,16 @@ +-- Historie overeni konektoru, poslednich pet zaznamu. +-- +-- Duvod, proc to je sloupec a ne jen odpoved endpointu: hlaska "HTTP 403" +-- nikoho nikam nedovede a to, co ji vysvetluje, je telo odpovedi sluzby. +-- To dosud existovalo jen v odpovedi na test a v logu containeru. Do logu +-- containeru se nikdo divat nechodi, takze to bylo totez jako nikde. +-- +-- Pet zaznamu proto, ze to je presne na otazku "co to delalo predtim, nez +-- jsem sahnul na udaje". Delsi historie je uz jina uloha a patri do vlastni +-- tabulky, ne do radku konektoru. +-- +-- Texty uvnitr jsou uz zredigovane (src/scripts/util.ts, createRedactor) +-- a zkracene na SCRIPT_ERROR_DETAIL_BYTES, takze tady nemuze byt udaj. + +ALTER TABLE connectors + ADD COLUMN IF NOT EXISTS checks jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/src/openapi.ts b/src/openapi.ts index 9ad239e..17e626a 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -222,6 +222,12 @@ export function buildOpenApiDocument() { status: { type: 'string', enum: ['untested', 'ok', 'error'] }, lastCheckAt: { type: 'string', format: 'date-time', nullable: true }, lastError: { type: 'string', nullable: true }, + checkCount: { + type: 'integer', + description: + 'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' + + '/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.', + }, isDefault: { type: 'boolean', description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.', @@ -1674,6 +1680,68 @@ export function buildOpenApiDocument() { checked: { type: 'string' }, status: { type: 'integer' }, message: { type: 'string' }, + baseUrl: { + type: 'string', + description: + 'Kam konektor miri. Vraci se i pri uspechu - zaklad adresy je ' + + 'z konfigurace a konektor ho smi prepsat, takze bez nej nerika ' + + 'kod odpovedi nic o tom, jestli se to trefilo na spravny stroj.', + }, + }, + }, + }, + }, + }, + '404': { description: 'Konektor neexistuje' }, + }, + }, + }, + '/api/dashboard/connectors/{id}/checks': { + get: { + tags: ['Konektory'], + summary: 'Historie overeni konektoru', + description: + 'Poslednich pet overeni, nejnovejsi prvni. U neuspechu nese zaznam cele telo ' + + 'odpovedi sluzby v poli detail - prave tam sluzba pise, co ji vadilo, a bez ' + + 'toho se neda rozlisit spatny udaj od zakazane IP adresy. Texty jsou uz ' + + 'zredigovane, pristupovy udaj v nich neni. Historie je zvlast a ne v seznamu ' + + 'konektoru proto, ze telo odpovedi byva o rady velikosti vetsi nez zbytek radku.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Zaznamy o overeni', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + limit: { type: 'integer' }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + at: { type: 'string', format: 'date-time' }, + ok: { type: 'boolean' }, + checked: { type: 'string' }, + status: { type: 'integer', nullable: true }, + message: { type: 'string' }, + detail: { type: 'string', nullable: true }, + request: { + type: 'object', + nullable: true, + description: + 'url je cela adresa vcetne serveru, bez query - v query muze byt tajemstvi.', + properties: { + method: { type: 'string' }, + path: { type: 'string' }, + url: { type: 'string' }, + }, + }, + }, + }, + }, }, }, }, diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index 8c586fd..a78e022 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -19,6 +19,7 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { accessFor } from '../data/access.js'; import { + CHECK_HISTORY, connectorCountsByService, createConnector, deleteConnector, @@ -28,6 +29,7 @@ import { toPublicConnector, updateConnector, validateConnectorValues, + type ConnectorCheck, } from '../data/connectorStore.js'; import { canSeeService, @@ -255,6 +257,11 @@ connectorsRouter.delete('/:id', async (req, res) => { * Vola `verifyPath` sluzby, coz je zamerne **cteci** volani, ktere vyzaduje * autorizaci. Kdyz ho sluzba nema, overi se jen `/health` - a odpoved to rekne * nahlas, aby si nikdo nemyslel, ze jsou overene i pristupove udaje. + * + * Ve vysledku je vzdy **cela adresa vcetne serveru**, i kdyz overeni projde. + * Zaklad adresy je z konfigurace (`SERVICES_BASE_URL`) a konektor ho smi + * prepsat, takze "vratilo 403" bez serveru nerika, jestli se to vubec trefilo + * na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde. */ connectorsRouter.post('/:id/test', async (req, res) => { const tenantId = tenantOrDeny(req, res); @@ -269,16 +276,29 @@ connectorsRouter.post('/:id/test', async (req, res) => { const target = resolveTarget(service.id, connector); if (!target.ready) { - await setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]); - return res.json({ - ok: false, - checked: 'nic', - message: `Napojení není hotové: ${target.missing.join(', ')}.`, - }); + const message = `Napojení není hotové: ${target.missing.join(', ')}.`; + await setConnectorStatus( + connector.id, + 'error', + message, + { + at: new Date().toISOString(), + ok: false, + checked: 'nic', + status: null, + message, + detail: null, + request: null, + }, + [tenantId], + ); + return res.json({ ok: false, checked: 'nic', message, baseUrl: target.baseUrl }); } const path = service.verifyPath ?? '/health'; const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby'; + // Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema. + const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path}`; const redact = createRedactor(Object.values(target.headers)); const controller = new AbortController(); @@ -295,43 +315,94 @@ connectorsRouter.post('/:id/test', async (req, res) => { }); const response = await http.get(path); - await setConnectorStatus(connector.id, 'ok', null, [tenantId]); + const message = service.verifyPath + ? 'Napojení funguje, přístupové údaje jsou platné.' + : 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.'; + const request = { method: 'GET', path, url: verifyUrl }; + + // Do historie patri i uspech. Bez nej se neda poznat, jestli konektor + // nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje. + await setConnectorStatus( + connector.id, + 'ok', + null, + { + at: new Date().toISOString(), + ok: true, + checked, + status: response.status, + message, + detail: null, + request, + }, + [tenantId], + ); return res.json({ ok: true, checked, status: response.status, - request: { method: 'GET', path }, - message: service.verifyPath - ? 'Napojení funguje, přístupové údaje jsou platné.' - : 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.', + request, + message, + baseUrl: target.baseUrl, }); } catch (err) { // Cela odpoved sluzby, ne jen "HTTP 401". Duvod je napsany prave v ni. const isScriptError = err instanceof ScriptError; const message = isScriptError ? redact(err.message) : redact(describe(err, 400)); + const status = isScriptError ? err.status : undefined; const detail = isScriptError ? err.detail ? redact(err.detail) : undefined : redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes)); - await setConnectorStatus(connector.id, 'error', message, [tenantId]); - console.warn( - `[connectors] test ${connector.id} selhal: ${message}` + (detail ? ` -${detail}` : ''), - ); + const request = + isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl }; + const check: ConnectorCheck = { + at: new Date().toISOString(), + ok: false, + checked, + status: status ?? null, + message, + detail: detail ?? null, + request, + }; + + await setConnectorStatus(connector.id, 'error', message, check, [tenantId]); + console.warn(`[connectors] test ${connector.id} selhal: ${message}`); // Neuspesne overeni neni chyba API, je to vysledek. Proto 200. return res.json({ ok: false, checked, message, - ...(isScriptError && err.status !== undefined ? { status: err.status } : {}), - request: isScriptError && err.request ? err.request : { method: 'GET', path }, + ...(status !== undefined ? { status } : {}), + request, + baseUrl: target.baseUrl, ...(detail ? { detail } : {}), }); } finally { clearTimeout(timer); } }); + +/** + * Historie overeni konektoru. + * + * Zvlast, ne v seznamu konektoru: kazdy zaznam nese cele telo odpovedi sluzby + * a u peti konektoru by to byly desitky kilobajtu, ktere nikdo necte. + * Nacte se az kdyz o to nekdo v portalu opravdu stoji. + */ +connectorsRouter.get('/:id/checks', async (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + + const connector = await getConnector(req.params.id, [tenantId]); + if (!connector) { + return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); + } + if (!serviceOrDeny(req, res, connector.serviceId, tenantId)) return; + + return res.json({ items: connector.checks, limit: CHECK_HISTORY }); +}); diff --git a/src/scripts/http.ts b/src/scripts/http.ts index 17ea617..7fd0512 100644 --- a/src/scripts/http.ts +++ b/src/scripts/http.ts @@ -86,35 +86,135 @@ function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOpti return url; } +/** + * Duvod, ktery sluzba napsala do tela odpovedi. + * + * Klice v poradi podle toho, jak konkretni obvykle jsou. `detail` u problem + * details byva cela veta, `error` byva jen kod jako `invalid_client`. + * Nekdo (vcetne nasi sluzby iDoklad) da do `detail` cele JSON tela od te + * skutecne sluzby, proto se retezec, ktery vypada jako JSON, rozbaluje dal. + */ +const reasonKeys = ['detail', 'error_description', 'message', 'Message', 'title', 'error']; + +/** Klice se seznamem toho, co konkretne chybelo nebo neproslo. */ +const reasonListKeys = ['missingHeaders', 'errors', 'Errors']; + +/** Strop na duvod v hlasce. Cele telo zustava v `detail`, tohle je jen veta. */ +const reasonBytes = 400; + +function reasonFromText(value: string, depth: number): string | null { + const raw = value.trim(); + if (raw === '') return null; + // HTML od reverse proxy nebo WAF. Do jednoradkove hlasky se necpe, cela + // stranka zustava v `detail`. + if (raw.startsWith('<')) return null; + if (raw.startsWith('{') || raw.startsWith('[')) { + const nested = reasonFromBody(raw, depth + 1); + return nested ?? truncate(raw, reasonBytes); + } + return truncate(raw, reasonBytes); +} + +function reasonFromValue(value: unknown, depth: number): string | null { + if (typeof value === 'string') return reasonFromText(value, depth); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + const parts = value.map((item) => reasonFromValue(item, depth)).filter(Boolean) as string[]; + return parts.length > 0 ? truncate(parts.join('; '), reasonBytes) : null; + } + return null; +} + +/** + * Vytahne z tela odpovedi vetu pro uzivatele. + * + * Vraci null, kdyz sluzba nenapsala nic pouzitelneho - to je taky informace + * a hlaska to pak rekne narovinu misto toho, aby to zamlcela. + */ +function reasonFromBody(detail: string | undefined, depth = 0): string | null { + const raw = (detail ?? '').trim(); + if (raw === '' || depth > 3) return null; + + if (!raw.startsWith('{') && !raw.startsWith('[')) return reasonFromText(raw, depth); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return truncate(raw, reasonBytes); + } + if (Array.isArray(parsed)) return reasonFromValue(parsed, depth); + if (parsed === null || typeof parsed !== 'object') return reasonFromValue(parsed, depth); + + const record = parsed as Record; + const parts: string[] = []; + + for (const key of reasonKeys) { + if (!(key in record)) continue; + const part = reasonFromValue(record[key], depth); + // `title` byva jen "Unauthorized", tedy to same, co uz rika HTTP kod. + if (part && !parts.includes(part)) parts.push(part); + if (parts.length > 0) break; + } + + for (const key of reasonListKeys) { + const part = reasonFromValue(record[key], depth); + if (part) parts.push(`${key}: ${part}`); + } + + if (parts.length === 0) return truncate(raw, reasonBytes); + return truncate(parts.join(' | '), reasonBytes); +} + /** * Chyba z HTTP kodu. * * `detail` je **cele telo odpovedi**, jen zredigovane a zkracene az na velkem * stropu. Prave tam cizi sluzba pise, co ji vadilo - "HTTP 400" samo o sobe - * nikoho nikam nedovede. + * nikoho nikam nedovede. Duvod z tela se navic vytahne rovnou do hlasky: + * rozbalovaci detail cte az ten, kdo uz vi, ze ma kam kliknout. + * + * 401 a 403 se rozlisuji, protoze **kazdy znamena neco jineho** a rada za ne + * je opacna. 401 = sluzba udaje dostala a odmitla je. 403 = udaje proti sobe + * nema, zakazuje samo volani, tedy typicky nepovolena IP adresa volajiciho + * nebo chybejici opravneni uctu. */ function statusError( status: number, request: ScriptRequestInfo, detail: string, ): ScriptError { - const where = `${request.method} ${request.path} vrátilo HTTP ${status}`; + const where = `${request.method} ${request.url} vrátilo HTTP ${status}`; + const reason = reasonFromBody(detail); + 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 }; if (retryableStatuses.has(status)) { - return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, options); + return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.${said}`, options); } - if (status === 401 || status === 403) { + if (status === 401) { return new ScriptError( 'config', - `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, + `Přístupové údaje odmítnuty: ${where}. Služba údaje dostala a neuznala je, ` + + `jde tedy o Client ID, Client Secret nebo jejich platnost, ne o IP adresu.${said}`, + options, + ); + } + if (status === 403) { + return new ScriptError( + 'config', + `Přístup zakázán: ${where}. Tohle není chyba tvaru údajů - služba zakazuje samo volání. ` + + `Nejčastěji nepovolená IP adresa volajícího, chybějící oprávnění účtu ` + + `nebo aplikace, pod kterou se volá.${said}`, options, ); } if (status === 404) { - return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, options); + return new ScriptError('terminal', `Záznam nenalezen: ${where}.${said}`, options); } - return new ScriptError('terminal', `Volání selhalo: ${where}.`, options); + return new ScriptError('terminal', `Volání selhalo: ${where}.${said}`, options); } function transportError(err: unknown, request: ScriptRequestInfo): ScriptError { @@ -131,7 +231,7 @@ Příčina: ${err.cause.message}` : ''; const detail = truncate(`${name}: ${message}${cause}`, config.errorDetailBytes); if (name === 'AbortError' || name === 'TimeoutError') { - return new ScriptError('timeout', `Volání ${request.path} nedoběhlo v limitu.`, { + return new ScriptError('timeout', `Volání ${request.url} nedoběhlo v limitu.`, { request, detail, cause: err, @@ -144,7 +244,7 @@ Příčina: ${err.cause.message}` : ''; cause: err, }); } - return new ScriptError('retryable', `Volání ${request.path} selhalo: ${message}`, { + return new ScriptError('retryable', `Volání ${request.url} selhalo: ${message}`, { request, detail, cause: err, @@ -171,8 +271,13 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { httpOptions?: ScriptHttpOptions, ): Promise> { const url = buildUrl(target, path, httpOptions); - // Do chyby jde jen cesta, ne cela adresa - v query muze byt tajemstvi. - const request: ScriptRequestInfo = { method, path: url.pathname }; + // Server ano, query ne. Bez serveru neni z hlasky poznat, kam to vlastne + // slo, s query by se do hlasky dostalo tajemstvi. + const request: ScriptRequestInfo = { + method, + path: url.pathname, + url: `${url.origin}${url.pathname}`, + }; const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE'; const startedAt = Date.now(); onCall(); @@ -220,7 +325,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { try { parsed = JSON.parse(raw); } catch { - throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, { + throw new ScriptError('terminal', `Odpověď ${request.url} není platný JSON.`, { status: response.status, request, detail: redact(truncate(raw, config.errorDetailBytes)), @@ -228,7 +333,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { } } - log(`${method} ${url.pathname} -> ${response.status} (${Date.now() - startedAt} ms)`); + log(`${method} ${request.url} -> ${response.status} (${Date.now() - startedAt} ms)`); const allowed = httpOptions?.allowStatus ?? []; if (!response.ok && !allowed.includes(response.status)) { diff --git a/src/scripts/types.ts b/src/scripts/types.ts index 25a1cdd..133dc79 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -161,8 +161,17 @@ export function isRetryableKind(kind: ScriptErrorKind): boolean { /** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */ export interface ScriptRequestInfo { method: string; - /** Cesta bez domeny. Cela adresa muze nest tajemstvi v query. */ + /** Cesta bez domeny. Kvuli zpetne kompatibilite, hlasky pouzivaji `url`. */ path: string; + /** + * Cela adresa **vcetne serveru**, ale bez query - v query muze byt tajemstvi. + * + * Bez serveru se hlaska neda pouzit: "/apps/idoklad/account/agenda vratilo + * 403" nerekne, jestli se to vubec trefilo na spravny stroj, nebo to zaridla + * cizi proxy cestou. A protoze zaklad adresy je z konfigurace a konektor ho + * smi prepsat, nesmi se hadat podle toho, kde je nasazeny portal. + */ + url: string; } export class ScriptError extends Error { diff --git a/web/src/components/dashboard/ErrorDetail.tsx b/web/src/components/dashboard/ErrorDetail.tsx index 12fc202..d96c158 100644 --- a/web/src/components/dashboard/ErrorDetail.tsx +++ b/web/src/components/dashboard/ErrorDetail.tsx @@ -18,24 +18,31 @@ export function ErrorDetail({ request, retryable, kind, + defaultOpen = false, className, }: { message: string; /** Cela odpoved sluzby nebo zasobnik volani. */ detail?: string; status?: number; - request?: { method: string; path: string }; + /** `url` je cela adresa vcetne serveru. Bez ni nejde poznat, kam to slo. */ + request?: { method: string; path: string; url?: string }; retryable?: boolean; kind?: string; + /** + * Rozbalit odpoved sluzby rovnou. U overeni konektoru ano: tam je telo + * odpovedi to jedine, co odlisi spatne udaje od nepovolene IP adresy. + */ + defaultOpen?: boolean; className?: string; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); const [copied, setCopied] = useState(false); /** Co se zkopiruje: vsechno, co o chybe vime, at to jde poslat dal. */ const fullText = [ message, - request ? `Volání: ${request.method} ${request.path}` : null, + request ? `Volání: ${request.method} ${request.url ?? request.path}` : null, status !== undefined ? `HTTP: ${status}` : null, kind ? `Druh: ${kind}` : null, retryable !== undefined @@ -71,7 +78,7 @@ export function ErrorDetail({

{request && ( - {request.method} {request.path} + {request.method} {request.url ?? request.path} )} {status !== undefined && HTTP {status}} diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index fdf0bf9..76fccb3 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -6,6 +6,7 @@ import { EyeOff, Pencil, Plus, + ScrollText, Star, Trash2, Wifi, @@ -20,14 +21,17 @@ import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { apiFetch, ApiError } from '@/lib/api'; import { cn } from '@/lib/cn'; +import { formatDateTime } from '@/lib/format'; import { serviceIcon } from '@/lib/serviceIcons'; import { usePageMeta } from '@/lib/usePageMeta'; import type { Connector, + ConnectorCheck, ConnectorTestResult, Service, ServiceCredentialField, ServiceOverview, + ServiceWithUsage, StorageStatus, } from '@/types/dashboard'; @@ -80,7 +84,8 @@ export default function Connectors() { useEffect(load, [load]); const servicesById = useMemo(() => { - const map = new Map(); + // ServiceWithUsage, ne Service: karta ukazuje i vychozi adresu sluzby. + const map = new Map(); for (const service of services?.items ?? []) map.set(service.id, service); return map; }, [services]); @@ -207,14 +212,21 @@ function ConnectorCard({ onChanged, }: { connector: Connector; - service: Service | undefined; + service: ServiceWithUsage | undefined; onEdit: () => void; onChanged: () => void; }) { + /** + * Kam konektor opravdu miri. Vychozi adresa je z konfigurace serveru + * a konektor ji smi prepsat, takze bez ni je "vratilo 403" jen pulka + * informace: nerika, jestli se to vubec trefilo na spravny stroj. + */ + const targetUrl = connector.baseUrl ?? service?.baseUrl ?? null; const Icon = serviceIcon(service?.icon ?? 'Plug'); const [busy, setBusy] = useState(false); const [test, setTest] = useState(null); const [error, setError] = useState(null); + const [logsOpen, setLogsOpen] = useState(false); async function handleTest() { setBusy(true); @@ -258,6 +270,11 @@ function ConnectorCard({ )}

{service?.name ?? connector.serviceId}

+ {targetUrl && ( +

+ {targetUrl} +

+ )} @@ -279,7 +296,7 @@ function ConnectorCard({

{connector.lastError} - Celé hlášení uvidíte po stisku Ověřit. + Celá odpověď služby je pod tlačítkem Logy.

)} @@ -301,6 +318,7 @@ function ConnectorCard({ detail={test.detail} status={test.status} request={test.request} + defaultOpen /> )} @@ -315,15 +333,125 @@ function ConnectorCard({ {busy ? 'Ověřuji...' : 'Ověřit'} + + + setLogsOpen(false)} + /> ); } +/** + * Poslednich pet overeni konektoru. + * + * Duvod, proc to je vlastni dialog a ne radek na karte: u chyby je podstatne + * cele telo odpovedi sluzby, a to je nekolik radku. Do karty se to neveslo, + * takze to dosud nebylo nikde - jen v odpovedi na test, ktera zmizela + * s prekreslenim stranky. + * + * Nacita se az pri otevreni. V seznamu konektoru by to byla zatez navic + * za neco, co vetsinu casu nikdo necte. + */ +function ConnectorLogs({ + connector, + targetUrl, + open, + onClose, +}: { + connector: Connector; + /** Kam konektor miri. V hlavicce dialogu, at je to videt bez rozklikavani. */ + targetUrl: string | null; + open: boolean; + onClose: () => void; +}) { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setItems(null); + setError(null); + apiFetch<{ items: ConnectorCheck[] }>(`/api/dashboard/connectors/${connector.id}/checks`) + .then((response) => setItems(response.items)) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : 'Logy se nepodařilo načíst.'); + }); + }, [open, connector.id]); + + return ( + + {targetUrl && ( +

+ {targetUrl} +

+ )} +
+ {error &&

{error}

} + {!error && items === null &&

Načítám...

} + {items !== null && items.length === 0 && ( +

+ Zatím tu nic není. Záznam vznikne při prvním stisku Ověřit. +

+ )} + + {items?.map((check, index) => ( +
+
+ {formatDateTime(check.at)} + {check.ok ? prošlo : selhalo} + Ověřeno: {check.checked} +
+ + {check.ok ? ( +

+ + + {check.message} + {check.request && ( + + {check.request.method} {check.request.url} + {check.status !== null && ` -> HTTP ${check.status}`} + + )} + +

+ ) : ( + + )} +
+ ))} +
+
+ ); +} + function StatusBadge({ connector }: { connector: Connector }) { if (!connector.ready) return Chybí údaje; if (connector.status === 'ok') return Ověřeno; diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 39c61da..93d1d2d 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -353,6 +353,8 @@ export interface Connector { status: 'untested' | 'ok' | 'error'; lastCheckAt: string | null; lastError: string | null; + /** Kolik zaznamu o overeni je v historii. Samotna historie se cte zvlast. */ + checkCount: number; isDefault: boolean; createdAt: string; updatedAt: string; @@ -380,16 +382,33 @@ export interface StorageStatus { location: string | null; } +/** + * Jeden zaznam v historii overeni konektoru. + * Nese cele telo odpovedi sluzby, protoze prave tam je napsane, co ji vadilo. + */ +export interface ConnectorCheck { + at: string; + ok: boolean; + checked: string; + status: number | null; + message: string; + detail: string | null; + /** Cela adresa vcetne serveru, bez query. */ + request: { method: string; path: string; url: string } | null; +} + export interface ConnectorTestResult { ok: boolean; /** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */ checked: string; status?: number; message: string; - /** Ktere volani to bylo. Bez toho je zprava jen pulka informace. */ - request?: { method: string; path: string }; + /** Ktere volani to bylo, vcetne serveru. Bez toho je zprava jen pulka informace. */ + request?: { method: string; path: string; url: string }; /** Cela odpoved sluzby. Tady je napsane, co ji vadilo. */ detail?: string; + /** Zaklad adresy, na kterou konektor miri. Vraci se i kdyz overeni projde. */ + baseUrl?: string; } export interface ServiceCatalog { @@ -743,7 +762,7 @@ export interface ScriptRunResult { status?: number; /** Cela odpoved sluzby, nebo zasobnik volani u chyby ve skriptu. */ detail?: string; - request?: { method: string; path: string }; + request?: { method: string; path: string; url?: string }; /** Jen mimo produkci. */ stack?: string; issues?: Array<{ field: string; message: string }>;