From d26153c2cc0333c73f366ac7b93051bdc25d9f08 Mon Sep 17 00:00:00 2001 From: sir_melonchen Date: Wed, 14 Oct 2026 14:25:38 +0200 Subject: [PATCH 1/3] chore(web): regenerate API types for the M5 contracts Co-Authored-By: Claude Opus 5.5 --- web/src/api/schema.d.ts | 954 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 954 insertions(+) diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 8fcf9a3..5dd2042 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -845,6 +845,229 @@ export interface paths { patch?: never; trace?: never; }; + "/clusters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listClusters"]; + put?: never; + /** + * @description Creates a cluster (permission settings.admin). A DNS cluster gets its + * own transfer key. Membership errors: cluster_role_mismatch (agent of + * the other role), agent_in_other_cluster, cluster_primary_exists + * (second primary), cluster_member_limit (DHCP: more than one primary + * and one secondary), invalid_ip (address). + */ + post: operations["createCluster"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/clusters/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get: operations["getCluster"]; + /** @description Replaces all mutable fields including the member list. The role is immutable and ignored. `version` must match. */ + put: operations["updateCluster"]; + post?: never; + /** + * @description Deletes the cluster with its members and transfer key. Fails with + * cluster_in_use (409) while zones or networks are assigned to it or it + * is a default cluster of the generator settings. + */ + delete: operations["deleteCluster"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tsig-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listTSIGKeys"]; + put?: never; + /** @description Creates a key with a random secret (permission settings.admin). Errors key_exists, invalid_key_name. */ + post: operations["createTSIGKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tsig-keys/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get?: never; + /** @description Changes the description. Name and algorithm are immutable and ignored. `version` must match. */ + put: operations["updateTSIGKey"]; + post?: never; + /** @description Fails with key_in_use (409) while zones use the key and with key_owned_by_cluster (409) for transfer keys. */ + delete: operations["deleteTSIGKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tsig-keys/{id}/secret": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + /** @description Returns the secret (permission settings.admin); every call is audited. */ + get: operations["getTSIGKeySecret"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tsig-keys/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Replaces the secret with a new random one; servers receive it with the next revision. */ + post: operations["rotateTSIGKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/zones": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listZones"]; + put?: never; + /** + * @description Creates an explicit zone (permission ipam.write). Errors zone_exists, + * invalid_domain, zone_kind_mismatch (reverse zones must end in + * in-addr.arpa or ip6.arpa, forward zones must not), cluster_role_mismatch + * (cluster is not a DNS cluster), not_found (cluster or key). + */ + post: operations["createZone"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/zones/derived": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listDerivedZones"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/zones/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + get: operations["getZone"]; + /** @description Replaces all mutable fields. Name and kind are immutable and ignored. `version` must match. */ + put: operations["updateZone"]; + post?: never; + delete: operations["deleteZone"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/zones/{id}/records": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + /** @description Records entered for the zone, sorted by name, type and data. A/AAAA/PTR records generated from addresses are not included. */ + get: operations["listRecords"]; + put?: never; + /** + * @description Creates a record (permission ipam.write). Fields that do not belong to + * the type must be empty or 0. Field errors use the codes of the + * generator's validation (e.g. invalid_label, invalid_domain, invalid_ip, + * invalid_ttl, invalid_txt, record_exists). + */ + post: operations["createRecord"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/zones/{id}/records/{recordId}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + recordId: string; + }; + cookie?: never; + }; + get?: never; + put: operations["updateRecord"]; + post?: never; + delete: operations["deleteRecord"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1001,6 +1224,8 @@ export interface components { dns_servers?: string[]; ntp_servers?: string[]; pools?: components["schemas"]["Pool"][]; + /** @description DHCP cluster serving the network; null for the default. */ + dhcp_cluster_id?: string | null; /** @description Required on update. */ version?: number; }; @@ -1029,6 +1254,8 @@ export interface components { dns_servers: string[]; ntp_servers: string[]; pools: components["schemas"]["Pool"][]; + /** @description DHCP cluster serving the network; null for the default. */ + dhcp_cluster_id: string | null; version: number; /** Format: date-time */ created_at: string; @@ -1127,6 +1354,8 @@ export interface components { forwarders: string[]; /** @description Zone file directory on the DNS hosts (must match the DNS agent configuration). */ zone_dir: string; + /** @description DNS cluster for zones without own cluster; null for the default group. */ + default_cluster_id?: string | null; }; dhcp: { interfaces: string[]; @@ -1138,12 +1367,20 @@ export interface components { lease_file: string; /** @description Directory of the Kea hook libraries; empty for the directory Kea was built with. */ hooks_dir: string; + /** @description DHCP cluster for networks without own cluster; null for the default group. */ + default_cluster_id?: string | null; + /** @description Path of the HA password file on the DHCP hosts (Kea HA; default /etc/kea/ddi-suite-ha-password). */ + ha_password_file?: string; }; }; GeneratedFile: { path: string; content: string; create_only: boolean; + /** @description Target of the file (see RevisionTarget); empty for the default group. */ + target: string; + /** @description Contains key material; content is always empty in the API. */ + secret: boolean; }; GeneratorProblem: { code: string; @@ -1155,6 +1392,7 @@ export interface components { dns: components["schemas"]["GeneratedFile"][]; dhcp: components["schemas"]["GeneratedFile"][]; warnings: components["schemas"]["GeneratorProblem"][]; + targets: components["schemas"]["RevisionTarget"][]; dns_error?: components["schemas"]["GeneratorProblem"]; dhcp_error?: components["schemas"]["GeneratorProblem"]; }; @@ -1204,6 +1442,7 @@ export interface components { /** Format: date-time */ revoked_at?: string; last_deployment?: components["schemas"]["Deployment"]; + cluster?: components["schemas"]["AgentCluster"]; }; EnrollmentToken: { id: string; @@ -1249,6 +1488,7 @@ export interface components { dns: components["schemas"]["GeneratedFile"][]; dhcp: components["schemas"]["GeneratedFile"][]; warnings: components["schemas"]["GeneratorProblem"][]; + targets: components["schemas"]["RevisionTarget"][]; }; Deployment: { id: number; @@ -1306,6 +1546,7 @@ export interface components { }; FileDiff: { role: components["schemas"]["AgentRole"]; + target: string; path: string; /** @enum {string} */ status: "added" | "removed" | "modified"; @@ -1339,6 +1580,209 @@ export interface components { /** Format: date-time */ created_at: string; }; + /** @enum {string} */ + MemberRole: "primary" | "secondary"; + /** @enum {string} */ + HAMode: "hot-standby" | "load-balancing"; + NameServer: { + name: string; + addresses: string[]; + }; + ClusterMemberRequest: { + agent_id: string; + role: components["schemas"]["MemberRole"]; + /** @description IP where the other members reach this one (DNS port 53, DHCP HA port). */ + address: string; + }; + ClusterMember: { + agent_id: string; + role: components["schemas"]["MemberRole"]; + address: string; + /** @description Current hostname of the agent. */ + hostname: string; + connected: boolean; + }; + /** @description role is required on create and ignored on update; ha_mode (DHCP only) defaults to hot-standby. */ + ClusterRequest: { + name: string; + role?: components["schemas"]["AgentRole"]; + description?: string; + members?: components["schemas"]["ClusterMemberRequest"][]; + /** @description DNS only: SOA MNAME of the cluster's zones; empty for the generator setting. */ + primary_ns?: string; + /** @description DNS only: NS records of the cluster's zones; empty for the generator setting. */ + name_servers?: components["schemas"]["NameServer"][]; + ha_mode?: components["schemas"]["HAMode"]; + /** @description DHCP only; default 8001. */ + ha_port?: number; + /** @description Required on update. */ + version?: number; + }; + Cluster: { + id: string; + name: string; + role: components["schemas"]["AgentRole"]; + description: string; + members: components["schemas"]["ClusterMember"][]; + primary_ns: string; + name_servers: components["schemas"]["NameServer"][]; + ha_mode: components["schemas"]["HAMode"]; + ha_port: number; + /** @description DNS clusters only. */ + transfer_key: { + id: string; + name: string; + } | null; + /** @description Explicit zones assigned to the cluster. */ + zone_count: number; + /** @description Networks assigned to the cluster. */ + network_count: number; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + AgentCluster: { + id: string; + name: string; + role: components["schemas"]["MemberRole"]; + address: string; + }; + /** @enum {string} */ + TSIGAlgorithm: "hmac-sha256" | "hmac-sha384" | "hmac-sha512"; + /** @description name and algorithm (default hmac-sha256) are used on create and ignored on update. */ + TSIGKeyRequest: { + name?: string; + algorithm?: components["schemas"]["TSIGAlgorithm"]; + description?: string; + /** @description Required on update. */ + version?: number; + }; + TSIGKey: { + id: string; + name: string; + algorithm: components["schemas"]["TSIGAlgorithm"]; + description: string; + /** @description Set for the transfer key of a DNS cluster. */ + cluster_id: string | null; + /** @description Zones that allow updates with this key. */ + zone_count: number; + version: number; + /** Format: date-time */ + created_at: string; + /** + * Format: date-time + * @description When the current secret was created. + */ + rotated_at: string; + }; + TSIGKeySecret: { + name: string; + algorithm: components["schemas"]["TSIGAlgorithm"]; + /** @description Base64. */ + secret: string; + }; + /** @enum {string} */ + ZoneKind: "forward" | "reverse"; + /** @enum {string} */ + ZoneMode: "managed" | "dynamic"; + /** @description name and kind (default: derived from the name) are used on create and ignored on update; mode defaults to managed. */ + ZoneRequest: { + name?: string; + kind?: components["schemas"]["ZoneKind"]; + mode?: components["schemas"]["ZoneMode"]; + /** @description DNS cluster; null for the default. */ + cluster_id?: string | null; + /** @description Seconds; null for the generator setting. */ + default_ttl?: number | null; + /** @description Dynamic zones: CIDRs allowed to send updates. */ + allow_update?: string[]; + /** @description Dynamic zones: TSIG keys allowed to send updates. */ + update_key_ids?: string[]; + description?: string; + /** @description Required on update. */ + version?: number; + }; + Zone: { + id: string; + name: string; + kind: components["schemas"]["ZoneKind"]; + mode: components["schemas"]["ZoneMode"]; + cluster_id: string | null; + default_ttl: number | null; + allow_update: string[]; + update_key_ids: string[]; + description: string; + record_count: number; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + DerivedZone: { + name: string; + kind: components["schemas"]["ZoneKind"]; + network_ids: string[]; + }; + /** @enum {string} */ + RecordType: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "SRV" | "NS" | "PTR"; + /** @description Fields that do not belong to the type must be empty or 0. */ + RecordRequest: { + /** @description Owner relative to the zone; @ for the apex. */ + name: string; + type: components["schemas"]["RecordType"]; + /** @description Seconds; null for the zone default. */ + ttl?: number | null; + /** @description A, AAAA. */ + address?: string; + /** @description CNAME, MX, SRV, NS, PTR: FQDN without trailing dot. */ + target?: string; + /** @description MX. */ + preference?: number; + /** @description SRV. */ + priority?: number; + /** @description SRV. */ + weight?: number; + /** @description SRV. */ + port?: number; + /** @description TXT: logical strings (split into 255-byte parts when rendered). */ + text?: string[]; + description?: string; + /** @description Required on update. */ + version?: number; + }; + Record: { + id: string; + zone_id: string; + name: string; + type: components["schemas"]["RecordType"]; + ttl: number | null; + address: string; + target: string; + preference: number; + priority: number; + weight: number; + port: number; + text: string[]; + description: string; + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description A set of files for a group of agents. Target empty: the default group (agents without cluster); otherwise the agent ID of a cluster member. */ + RevisionTarget: { + role: components["schemas"]["AgentRole"]; + target: string; + cluster_id: string | null; + /** @description Name at the time of the revision; empty for the default group. */ + cluster_name: string; + /** @enum {string} */ + member_role: "primary" | "secondary" | ""; + }; }; responses: { /** @description Error (RFC 9457 problem document). */ @@ -3047,4 +3491,514 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + listClusters: { + parameters: { + query?: { + role?: components["schemas"]["AgentRole"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Clusters sorted by role and name. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Cluster"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createCluster: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ClusterRequest"]; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Cluster"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getCluster: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The cluster. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Cluster"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateCluster: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ClusterRequest"]; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Cluster"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteCluster: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + listTSIGKeys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description TSIG keys (without secrets) sorted by name. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["TSIGKey"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createTSIGKey: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TSIGKeyRequest"]; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TSIGKey"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateTSIGKey: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TSIGKeyRequest"]; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TSIGKey"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteTSIGKey: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + getTSIGKeySecret: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The key with its secret. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TSIGKeySecret"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + rotateTSIGKey: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The key. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TSIGKey"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listZones: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Explicit zones sorted by name. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Zone"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createZone: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ZoneRequest"]; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Zone"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listDerivedZones: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Zones derived from networks that no explicit zone covers, sorted by name. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["DerivedZone"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getZone: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The zone. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Zone"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateZone: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ZoneRequest"]; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Zone"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteZone: { + parameters: { + query?: { + /** @description Also delete the zone's records; without it a zone with records fails with zone_not_empty (409). */ + force?: boolean; + }; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + listRecords: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The records. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Record"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createRecord: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordRequest"]; + }; + }; + responses: { + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Record"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateRecord: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + recordId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordRequest"]; + }; + }; + responses: { + /** @description Updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Record"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteRecord: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ID"]; + recordId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; } From 15bdb03c1414afa18c4e45df0e019c00a2e6ca94 Mon Sep 17 00:00:00 2001 From: sir_melonchen Date: Wed, 14 Oct 2026 14:51:26 +0200 Subject: [PATCH 2/3] feat(web): clusters, TSIG keys, zones and records (M5) - Clusters page: DNS/DHCP clusters with members, transfer key, DNS options and Kea HA settings; member picking limited to free agents of the role, client-side checks of the membership rules. - TSIG keys page: create, edit, rotate, delete; secrets only after an explicit, audited retrieval and shown once. - Zones page with derived zones ("take over") and zone detail with type-specific record forms and hints for dynamic zones and AD. - Networks, agents and generator settings show/assign clusters; files, diffs and pending changes are grouped by target, secret files are never shown. - Pages are lazy-loaded per route (main bundle 600 KB -> 480 KB). Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 14 +- web/src/App.tsx | 236 +++++---- web/src/app.css | 56 +++ web/src/clusters.ts | 128 +++++ web/src/components/ClusterSelect.tsx | 36 ++ web/src/components/DiffView.tsx | 63 ++- web/src/components/Layout.tsx | 10 +- web/src/components/ui.tsx | 21 +- web/src/deploy.test.tsx | 2 + web/src/live.ts | 12 +- web/src/locales/de.json | 257 +++++++++- web/src/locales/en.json | 257 +++++++++- web/src/nameServers.ts | 20 + web/src/pages/AddressesPage.tsx | 24 +- web/src/pages/AgentsPage.tsx | 50 +- web/src/pages/ClustersPage.tsx | 626 ++++++++++++++++++++++++ web/src/pages/ConfigPage.tsx | 54 +- web/src/pages/GeneratorSettingsPage.tsx | 72 ++- web/src/pages/NetworkForm.tsx | 22 + web/src/pages/NetworksPage.tsx | 6 + web/src/pages/RecordForm.tsx | 281 +++++++++++ web/src/pages/RevisionsPage.tsx | 18 +- web/src/pages/TSIGKeysPage.tsx | 376 ++++++++++++++ web/src/pages/ZoneForm.tsx | 339 +++++++++++++ web/src/pages/ZonesPage.tsx | 453 +++++++++++++++++ web/src/records.ts | 47 ++ web/src/test-utils.tsx | 6 + 27 files changed, 3273 insertions(+), 213 deletions(-) create mode 100644 web/src/clusters.ts create mode 100644 web/src/components/ClusterSelect.tsx create mode 100644 web/src/nameServers.ts create mode 100644 web/src/pages/ClustersPage.tsx create mode 100644 web/src/pages/RecordForm.tsx create mode 100644 web/src/pages/TSIGKeysPage.tsx create mode 100644 web/src/pages/ZoneForm.tsx create mode 100644 web/src/pages/ZonesPage.tsx create mode 100644 web/src/records.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 33be9e9..913dfe5 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -165,7 +165,8 @@ describe('addresses', () => { renderApp('/addresses'); const row = (await screen.findByText('192.0.2.150')).closest('tr'); if (!row) throw new Error('row not found'); - await userEvent.click(within(row).getByRole('button', { name: 'Fest reservieren' })); + expect(within(row).getByText('aktiv')).toBeInTheDocument(); + await userEvent.click(within(row).getByRole('button', { name: 'Als Reservierung übernehmen' })); const dialog = await screen.findByRole('dialog'); expect( @@ -257,8 +258,17 @@ describe('config preview', () => { 'GET /api/v1/config/preview': () => ({ body: { dns: [], - dhcp: [{ path: 'kea-dhcp4.conf', content: '{"Dhcp4": {}}', create_only: false }], + dhcp: [ + { + path: 'kea-dhcp4.conf', + content: '{"Dhcp4": {}}', + create_only: false, + target: '', + secret: false, + }, + ], warnings: [{ code: 'dns.no_domain', params: { ip: '192.0.2.9', hostname: 'pc' } }], + targets: [], dns_error: { code: 'dns_settings_missing' }, }, }), diff --git a/web/src/App.tsx b/web/src/App.tsx index 0e11c4c..80c673b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LicenseRef-DDI-Proprietary import { QueryClientProvider } from '@tanstack/react-query'; -import { useState, type ReactNode } from 'react'; +import { lazy, useState, type ComponentType, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { createBrowserRouter, @@ -16,20 +16,90 @@ import { SessionProvider } from './auth/session'; import { Layout } from './components/Layout'; import { Spinner } from './components/ui'; import { AccountPage, ForcedPasswordPage } from './pages/AccountPage'; -import { AddressesPage } from './pages/AddressesPage'; -import { AgentDetailPage, AgentsPage } from './pages/AgentsPage'; -import { AuditPage } from './pages/AuditPage'; -import { ConfigPage } from './pages/ConfigPage'; -import { GeneratorSettingsPage } from './pages/GeneratorSettingsPage'; -import { LeasesPage } from './pages/LeasesPage'; -import { LicensePage } from './pages/LicensePage'; import { LoginPage } from './pages/LoginPage'; -import { NetworkDetailPage, NetworksPage } from './pages/NetworksPage'; -import { OIDCSettingsPage } from './pages/OIDCSettingsPage'; -import { RevisionDetailPage, RevisionsPage } from './pages/RevisionsPage'; -import { UsersPage } from './pages/UsersPage'; import { createQueryClient } from './queryClient'; +/** + * Pages behind the login are split into chunks of their own and loaded on + * first use; Layout shows a spinner meanwhile (Suspense). + */ +function lazyPage(load: () => Promise, pick: (m: M) => ComponentType) { + return lazy(() => load().then((m) => ({ default: pick(m) }))); +} + +const AddressesPage = lazyPage( + () => import('./pages/AddressesPage'), + (m) => m.AddressesPage, +); +const AgentsPage = lazyPage( + () => import('./pages/AgentsPage'), + (m) => m.AgentsPage, +); +const AgentDetailPage = lazyPage( + () => import('./pages/AgentsPage'), + (m) => m.AgentDetailPage, +); +const AuditPage = lazyPage( + () => import('./pages/AuditPage'), + (m) => m.AuditPage, +); +const ClustersPage = lazyPage( + () => import('./pages/ClustersPage'), + (m) => m.ClustersPage, +); +const ConfigPage = lazyPage( + () => import('./pages/ConfigPage'), + (m) => m.ConfigPage, +); +const GeneratorSettingsPage = lazyPage( + () => import('./pages/GeneratorSettingsPage'), + (m) => m.GeneratorSettingsPage, +); +const LeasesPage = lazyPage( + () => import('./pages/LeasesPage'), + (m) => m.LeasesPage, +); +const LicensePage = lazyPage( + () => import('./pages/LicensePage'), + (m) => m.LicensePage, +); +const NetworksPage = lazyPage( + () => import('./pages/NetworksPage'), + (m) => m.NetworksPage, +); +const NetworkDetailPage = lazyPage( + () => import('./pages/NetworksPage'), + (m) => m.NetworkDetailPage, +); +const OIDCSettingsPage = lazyPage( + () => import('./pages/OIDCSettingsPage'), + (m) => m.OIDCSettingsPage, +); +const RevisionsPage = lazyPage( + () => import('./pages/RevisionsPage'), + (m) => m.RevisionsPage, +); +const RevisionDetailPage = lazyPage( + () => import('./pages/RevisionsPage'), + (m) => m.RevisionDetailPage, +); +const TSIGKeysPage = lazyPage( + () => import('./pages/TSIGKeysPage'), + (m) => m.TSIGKeysPage, +); +const UsersPage = lazyPage( + () => import('./pages/UsersPage'), + (m) => m.UsersPage, +); +const ZonesPage = lazyPage( + () => import('./pages/ZonesPage'), + (m) => m.ZonesPage, +); +const ZoneDetailPage = lazyPage( + () => import('./pages/ZonesPage'), + (m) => m.ZoneDetailPage, +); + /** Redirects to the login page unless a complete session exists. */ function RequireSession() { const { session, loading } = useSession(); @@ -60,6 +130,18 @@ function NotFound() { ); } +/** A route rendering Page for users with the permission. */ +function guarded(path: string, perm: Permission, Page: ComponentType): RouteObject { + return { + path, + element: ( + + + + ), + }; +} + export const routes: RouteObject[] = [ { path: '/login', element: }, { @@ -70,118 +152,24 @@ export const routes: RouteObject[] = [ element: , children: [ { index: true, element: }, - { - path: 'networks', - element: ( - - - - ), - }, - { - path: 'networks/:id', - element: ( - - - - ), - }, - { - path: 'addresses', - element: ( - - - - ), - }, - { - path: 'license', - element: ( - - - - ), - }, - { - path: 'users', - element: ( - - - - ), - }, - { - path: 'audit', - element: ( - - - - ), - }, - { - path: 'settings/oidc', - element: ( - - - - ), - }, - { - path: 'config', - element: ( - - - - ), - }, - { - path: 'revisions', - element: ( - - - - ), - }, - { - path: 'revisions/:number', - element: ( - - - - ), - }, - { - path: 'agents', - element: ( - - - - ), - }, - { - path: 'agents/:id', - element: ( - - - - ), - }, - { - path: 'leases', - element: ( - - - - ), - }, - { - path: 'settings/generator', - element: ( - - - - ), - }, + guarded('networks', 'ipam.read', NetworksPage), + guarded('networks/:id', 'ipam.read', NetworkDetailPage), + guarded('addresses', 'ipam.read', AddressesPage), + guarded('zones', 'ipam.read', ZonesPage), + guarded('zones/:id', 'ipam.read', ZoneDetailPage), + guarded('license', 'license.read', LicensePage), + guarded('users', 'users.admin', UsersPage), + guarded('audit', 'audit.read', AuditPage), + guarded('settings/oidc', 'settings.admin', OIDCSettingsPage), + guarded('config', 'ipam.read', ConfigPage), + guarded('revisions', 'ipam.read', RevisionsPage), + guarded('revisions/:number', 'ipam.read', RevisionDetailPage), + guarded('agents', 'ipam.read', AgentsPage), + guarded('agents/:id', 'ipam.read', AgentDetailPage), + guarded('clusters', 'ipam.read', ClustersPage), + guarded('leases', 'ipam.read', LeasesPage), + guarded('settings/generator', 'settings.admin', GeneratorSettingsPage), + guarded('settings/tsig-keys', 'settings.admin', TSIGKeysPage), { path: 'account', element: }, { path: '*', element: }, ], diff --git a/web/src/app.css b/web/src/app.css index 7dd0505..75c9921 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -658,6 +658,62 @@ details.file summary { cursor: pointer; } +div.file-secret { + border-top: 1px solid var(--border); + padding: 6px 0; +} + +.target-group + .target-group { + margin-top: 12px; +} + +.target-group h3 { + margin: 8px 0 4px; + font-size: 0.95rem; +} + +/* ---- Clusters, zones, records ---------------------------------------- */ + +.member-row { + display: grid; + grid-template-columns: minmax(10rem, 2fr) minmax(7rem, 1fr) minmax(9rem, 1.5fr) auto; + gap: 8px; + align-items: start; + margin-bottom: 6px; +} + +.member-list { + list-style: none; + margin: 0; + padding: 0; +} + +.member-list li + li { + margin-top: 2px; +} + +.text-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.text-row input { + flex: 1; +} + +.secret-value { + word-break: break-all; + white-space: pre-wrap; +} + +.check-list { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; +} + .file-content { max-height: 480px; overflow: auto; diff --git a/web/src/clusters.ts b/web/src/clusters.ts new file mode 100644 index 0000000..7c8b760 --- /dev/null +++ b/web/src/clusters.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary +import { useQuery } from '@tanstack/react-query'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import { api, unwrap, type Schemas } from './api/client'; + +export type Cluster = Schemas['Cluster']; +export type AgentRole = Schemas['AgentRole']; +export type MemberRole = Schemas['MemberRole']; +type Agent = Schemas['Agent']; +type RevisionTarget = Schemas['RevisionTarget']; + +export const clustersKey = ['clusters'] as const; +export const tsigKeysKey = ['tsig-keys'] as const; +export const zonesKey = ['zones'] as const; + +/** All clusters of both roles (sorted by role and name by the server). */ +export function useClusters(enabled = true) { + return useQuery({ + queryKey: clustersKey, + queryFn: () => unwrap(api.GET('/clusters')), + enabled, + }); +} + +/** TSIG keys without secrets. */ +export function useTSIGKeys(enabled = true) { + return useQuery({ + queryKey: tsigKeysKey, + queryFn: () => unwrap(api.GET('/tsig-keys')), + enabled, + }); +} + +/** The agent list; shares its cache with the agents page. */ +export function useAgents(enabled = true) { + return useQuery({ + queryKey: ['agents'], + queryFn: () => unwrap(api.GET('/agents')), + enabled, + }); +} + +/** The IP part of a remote address such as "192.0.2.53:41234" or "[2001:db8::1]:41234". */ +export function hostOf(remote: string | undefined): string { + if (!remote) return ''; + const v6 = /^\[([^\]]+)\](?::\d+)?$/.exec(remote); + if (v6) return v6[1] ?? ''; + const v4 = /^([^:]+):\d+$/.exec(remote); + return v4 ? (v4[1] ?? '') : remote; +} + +/** + * Label of a generated-file target: the default group, or + * "Cluster X · Primary · host". Names come from the revision's target + * description, hostnames from the agent list (fallback: the agent ID). + */ +export function targetLabel( + t: TFunction, + target: string, + info: RevisionTarget | undefined, + agent: Agent | undefined, +): string { + if (target === '') return t('targets.default'); + const clusterName = info ? info.cluster_name : agent?.cluster?.name; + const memberRole = info ? info.member_role : agent?.cluster?.role; + const parts: string[] = []; + if (clusterName) parts.push(t('targets.cluster', { name: clusterName })); + if (memberRole) parts.push(t(`clusters.memberRoles.${memberRole}`)); + parts.push(agent?.hostname || target); + return parts.join(' · '); +} + +/** Whether files belong to cluster targets (only then are they grouped). */ +export function hasClusterTargets(files: { target: string }[]): boolean { + return files.some((f) => f.target !== ''); +} + +export interface TargetGroup { + role: AgentRole; + target: string; + files: F[]; +} + +/** + * Groups files by role and target: the default group first, then the + * targets in the order of the revision's target list. + */ +export function groupByTarget( + files: F[], + roleOf: (f: F) => AgentRole, + targets: RevisionTarget[] = [], +): TargetGroup[] { + const groups = new Map>(); + for (const f of files) { + const role = roleOf(f); + const key = `${role} ${f.target}`; + const g = groups.get(key); + if (g) g.files.push(f); + else groups.set(key, { role, target: f.target, files: [f] }); + } + const order = targets.map((x) => `${x.role} ${x.target}`); + const rank = (g: TargetGroup) => { + const i = order.indexOf(`${g.role} ${g.target}`); + return ( + (g.role === 'dns' ? 0 : 2) + (g.target === '' ? 0 : 1) + (i < 0 ? order.length : i) / 1e6 + ); + }; + return [...groups.values()].sort((a, b) => rank(a) - rank(b)); +} + +/** + * Returns a labeller for targets. The agent list is only fetched when + * needed (cluster targets present). + */ +export function useTargetLabels(targets: RevisionTarget[], needed: boolean) { + const { t } = useTranslation(); + const agents = useAgents(needed); + const list = agents.data?.items ?? []; + return (role: AgentRole, target: string) => + targetLabel( + t, + target, + targets.find((x) => x.role === role && x.target === target), + list.find((a) => a.id === target), + ); +} diff --git a/web/src/components/ClusterSelect.tsx b/web/src/components/ClusterSelect.tsx new file mode 100644 index 0000000..794ff8c --- /dev/null +++ b/web/src/components/ClusterSelect.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary +import { useClusters, type AgentRole } from '../clusters'; + +interface Props { + id: string; + role: AgentRole; + /** Cluster ID; null for the default. */ + value: string | null; + onChange: (value: string | null) => void; + /** Text of the empty option (default cluster or default group). */ + emptyLabel: string; +} + +/** Select of the clusters of one role with an empty option for the default. */ +export function ClusterSelect({ id, role, value, onChange, emptyLabel }: Props) { + const clusters = useClusters(); + const list = (clusters.data?.items ?? []).filter((c) => c.role === role); + return ( + + ); +} diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index 9a02d49..2c5bbc6 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -3,11 +3,13 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { Schemas } from '../api/client'; +import { groupByTarget, hasClusterTargets, useTargetLabels } from '../clusters'; import { parseUnifiedDiff, type DiffLineKind } from '../diff'; import { Badge } from './ui'; type FileDiff = Schemas['FileDiff']; type DiffStatus = FileDiff['status']; +type RevisionTarget = Schemas['RevisionTarget']; const statusKind: Record = { added: 'success', @@ -20,9 +22,30 @@ const sign: Record = { add: '+', del: '−', ctx: '', hunk /** Up to this many changed lines in total, all files start expanded. */ const autoOpenLines = 400; +/** + * Secret files (key material) come without diff text (ADR 0023); every + * other changed file has at least one hunk. + */ +function isSecret(file: FileDiff): boolean { + return file.diff === ''; +} + function FileDiffView({ file, defaultOpen }: { file: FileDiff; defaultOpen: boolean }) { const { t } = useTranslation(); const [open, setOpen] = useState(defaultOpen); + if (isSecret(file)) { + return ( +
+
+ {file.path} + {t(`agents.roles.${file.role}`)} + {t(`diff.status.${file.status}`)} + {t('config.secret')} + {t('config.secretContent')} +
+
+ ); + } return (
@@ -79,19 +102,43 @@ function FileDiffView({ file, defaultOpen }: { file: FileDiff; defaultOpen: bool ); } -/** Unified diffs per file (revision comparison or pending changes). */ -export function DiffView({ files, emptyText }: { files: FileDiff[]; emptyText?: string }) { +/** + * Unified diffs per file (revision comparison or pending changes). With + * cluster targets, files are grouped per role and target ("default group", + * "Cluster X · Primary · host"). + */ +export function DiffView({ + files, + emptyText, + targets = [], +}: { + files: FileDiff[]; + emptyText?: string; + /** Target descriptions for the labels (e.g. of the revision shown). */ + targets?: RevisionTarget[]; +}) { const { t } = useTranslation(); + const grouped = hasClusterTargets(files); + const label = useTargetLabels(targets, grouped); if (files.length === 0) return

{emptyText ?? t('diff.empty')}

; const total = files.reduce((n, f) => n + f.inserted + f.deleted, 0); + const view = (f: FileDiff) => ( + + ); + if (!grouped) return
{files.map(view)}
; return (
- {files.map((f) => ( - + {groupByTarget(files, (f) => f.role, targets).map((g) => ( +
+

+ {t(`agents.roles.${g.role}`)} · {label(g.role, g.target)} +

+ {g.files.map(view)} +
))}
); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 181b3ed..d6abf46 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,4 +1,5 @@ // SPDX-License-Identifier: LicenseRef-DDI-Proprietary +import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, NavLink, Outlet, useNavigate } from 'react-router'; @@ -11,6 +12,7 @@ import { reasonText, useLicense } from '../license'; import { LiveContext, useLiveEvents } from '../live'; import { usePendingChanges } from '../pending'; import { themes, useTheme, type Theme } from '../theme'; +import { Spinner } from './ui'; const themeLabelKey = { system: 'settings.themeSystem', @@ -104,13 +106,16 @@ export function Layout() { { to: '/networks', label: t('nav.networks'), show: can('ipam.read') }, { to: '/addresses', label: t('nav.addresses'), show: can('ipam.read') }, { to: '/leases', label: t('nav.leases'), show: can('ipam.read') }, + { to: '/zones', label: t('nav.zones'), show: can('ipam.read') }, { to: '/config', label: t('nav.config'), show: can('ipam.read') }, { to: '/revisions', label: t('nav.revisions'), show: can('ipam.read') }, { to: '/agents', label: t('nav.agents'), show: can('ipam.read') }, + { to: '/clusters', label: t('nav.clusters'), show: can('ipam.read') }, { to: '/license', label: t('nav.license'), show: can('license.read') }, { to: '/users', label: t('nav.users'), show: can('users.admin') }, { to: '/audit', label: t('nav.audit'), show: can('audit.read') }, { to: '/settings/generator', label: t('nav.generator'), show: can('settings.admin') }, + { to: '/settings/tsig-keys', label: t('nav.tsigKeys'), show: can('settings.admin') }, { to: '/settings/oidc', label: t('nav.settings'), show: can('settings.admin') }, ]; @@ -147,7 +152,10 @@ export function Layout() { {can('ipam.read') && }
- + {/* Pages are loaded on demand (code splitting, see App.tsx). */} + }> + +