Chybova hlaseni konektoru rikaji, co se stalo, a kam to slo
Duvod od sluzby jde primo do hlasky: z tela odpovedi se vytahne detail, error_description, message, title, error i seznam missingHeaders. Retezec, ktery vypada jako JSON, se rozbaluje dal - sluzba iDoklad presne takhle predava telo od iDokladu samotneho. Kdyz sluzba nenapsala nic, rekne se to. 401 a 403 uz nejsou jedna hlaska. 401 = udaje sluzba dostala a neuznala je. 403 = tvar udaju je v poradku, zakazuje se samo volani. V kazde hlasce je cela adresa vcetne serveru (ScriptRequestInfo.url), bez query - v query muze byt tajemstvi. 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 a v odpovedi na test, i kdyz overeni projde. Tlacitko Logy na karte konektoru a historie poslednich peti overeni. Odpoved sluzby dosud 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 /connectors/:id/checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6297bbf480
commit
5a124a53d8
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Connector | undefined> {
|
||||
return repository.setStatus(id, status, error, tenantIds);
|
||||
return repository.setStatus(id, status, error, check, tenantIds);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ConnectorRow>(
|
||||
`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;
|
||||
},
|
||||
|
||||
@@ -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<Connector | undefined>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+89
-18
@@ -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 });
|
||||
});
|
||||
|
||||
+118
-13
@@ -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<string, unknown>;
|
||||
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<ScriptHttpResponse<T>> {
|
||||
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)) {
|
||||
|
||||
+10
-1
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
{request && (
|
||||
<span className="font-mono">
|
||||
{request.method} {request.path}
|
||||
{request.method} {request.url ?? request.path}
|
||||
</span>
|
||||
)}
|
||||
{status !== undefined && <span>HTTP {status}</span>}
|
||||
|
||||
@@ -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<string, Service>();
|
||||
// ServiceWithUsage, ne Service: karta ukazuje i vychozi adresu sluzby.
|
||||
const map = new Map<string, ServiceWithUsage>();
|
||||
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<ConnectorTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
|
||||
async function handleTest() {
|
||||
setBusy(true);
|
||||
@@ -258,6 +270,11 @@ function ConnectorCard({
|
||||
)}
|
||||
</h2>
|
||||
<p className="truncate text-xs text-white/40">{service?.name ?? connector.serviceId}</p>
|
||||
{targetUrl && (
|
||||
<p className="truncate font-mono text-xs text-white/30" title={targetUrl}>
|
||||
{targetUrl}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +296,7 @@ function ConnectorCard({
|
||||
<p className="mt-2 text-xs text-danger-400">
|
||||
{connector.lastError}
|
||||
<span className="mt-0.5 block text-white/35">
|
||||
Celé hlášení uvidíte po stisku Ověřit.
|
||||
Celá odpověď služby je pod tlačítkem Logy.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
@@ -301,6 +318,7 @@ function ConnectorCard({
|
||||
detail={test.detail}
|
||||
status={test.status}
|
||||
request={test.request}
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -315,15 +333,125 @@ function ConnectorCard({
|
||||
<Wifi className="size-3.5" />
|
||||
{busy ? 'Ověřuji...' : 'Ověřit'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setLogsOpen(true)}>
|
||||
<ScrollText className="size-3.5" />
|
||||
Logy
|
||||
{connector.checkCount > 0 && (
|
||||
<span className="text-white/40">{connector.checkCount}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={busy}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Smazat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConnectorLogs
|
||||
connector={connector}
|
||||
targetUrl={targetUrl}
|
||||
open={logsOpen}
|
||||
onClose={() => setLogsOpen(false)}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ConnectorCheck[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={`Logy: ${connector.name}`}
|
||||
description="Posledních pět ověření. U neúspěchu je vidět celá odpověď služby."
|
||||
>
|
||||
{targetUrl && (
|
||||
<p className="border-b border-ink-600/60 px-5 py-3 font-mono text-xs break-all text-white/40">
|
||||
{targetUrl}
|
||||
</p>
|
||||
)}
|
||||
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-5">
|
||||
{error && <p className="text-sm text-danger-400">{error}</p>}
|
||||
{!error && items === null && <p className="text-sm text-white/40">Načítám...</p>}
|
||||
{items !== null && items.length === 0 && (
|
||||
<p className="text-sm text-white/40">
|
||||
Zatím tu nic není. Záznam vznikne při prvním stisku Ověřit.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items?.map((check, index) => (
|
||||
<div key={`${check.at}-${index}`} className="rounded-lg border border-ink-600/60 p-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
<span className="text-white/70">{formatDateTime(check.at)}</span>
|
||||
{check.ok ? <Badge tone="ok">prošlo</Badge> : <Badge tone="danger">selhalo</Badge>}
|
||||
<span>Ověřeno: {check.checked}</span>
|
||||
</div>
|
||||
|
||||
{check.ok ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-sm text-ok-400">
|
||||
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{check.message}
|
||||
{check.request && (
|
||||
<span className="mt-0.5 block font-mono text-xs break-all text-white/40">
|
||||
{check.request.method} {check.request.url}
|
||||
{check.status !== null && ` -> HTTP ${check.status}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<ErrorDetail
|
||||
className="mt-2"
|
||||
message={check.message}
|
||||
detail={check.detail ?? undefined}
|
||||
status={check.status ?? undefined}
|
||||
request={check.request ?? undefined}
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ connector }: { connector: Connector }) {
|
||||
if (!connector.ready) return <Badge tone="warn">Chybí údaje</Badge>;
|
||||
if (connector.status === 'ok') return <Badge tone="ok">Ověřeno</Badge>;
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
Reference in New Issue
Block a user