diff --git a/Dockerfile b/Dockerfile index 9df64fc..c9a26fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG NGINX_IMAGE=nginx:1.27.3-alpine FROM ${NODE_IMAGE} AS build ARG ANGULAR_VERSION=18 -ARG OKDP_UI_VERSION=0.3.0 +ARG OKDP_UI_VERSION=0.4.0 LABEL name="OKDP UI" \ description="OKDP UI" \ diff --git a/angular.json b/angular.json index 5ba3c31..ce01e93 100644 --- a/angular.json +++ b/angular.json @@ -36,7 +36,12 @@ "node_modules/modern-normalize/modern-normalize.css", "node_modules/bootstrap/dist/css/bootstrap.min.css", "node_modules/@angular/material/prebuilt-themes/indigo-pink.css", + "node_modules/@xterm/xterm/css/xterm.css", + "node_modules/prismjs/themes/prism.css", "src/styles.scss" + ], + "scripts": [ + "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" ] }, "configurations": { diff --git a/helm/okdp-ui/Chart.yaml b/helm/okdp-ui/Chart.yaml index 6e64d6f..b6d2c3d 100644 --- a/helm/okdp-ui/Chart.yaml +++ b/helm/okdp-ui/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: okdp-ui description: OKDP UI Helm chart type: application -version: 0.3.0 -appVersion: "0.3.0" +version: "0.4.0" +appVersion: "0.4.0" home: https://okdp.io maintainers: - email: idir.izitounene@kubotal.io diff --git a/helm/okdp-ui/README.md b/helm/okdp-ui/README.md index f655ae5..677c1e7 100644 --- a/helm/okdp-ui/README.md +++ b/helm/okdp-ui/README.md @@ -1,6 +1,6 @@ # okdp-ui -![Version: 0.3.0](https://img.shields.io/badge/Version-0.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.3.0](https://img.shields.io/badge/AppVersion-0.3.0-informational?style=flat-square) +![Version: 0.4.0](https://img.shields.io/badge/Version-0.4.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.4.0](https://img.shields.io/badge/AppVersion-0.4.0-informational?style=flat-square) OKDP UI Helm chart @@ -28,7 +28,7 @@ OKDP UI Helm chart | fullnameOverride | string | `""` | Overrides the release name. | | image.pullPolicy | string | `"Always"` | Image pull policy. | | image.repository | string | `"quay.io/okdp/okdp-ui"` | Docker image registry. | -| image.tag | string | `"0.3.0"` | Image tag. | +| image.tag | string | `"0.4.0"` | Image tag. | | imagePullSecrets | list | `[]` | Secrets to be used for pulling images from private Docker registries. | | ingress.annotations | object | `{}` | | | ingress.className | string | `""` | Specify the ingress class (Kubernetes >= 1.18). | diff --git a/helm/okdp-ui/values.yaml b/helm/okdp-ui/values.yaml index f08b14e..3ebc4a5 100644 --- a/helm/okdp-ui/values.yaml +++ b/helm/okdp-ui/values.yaml @@ -7,7 +7,7 @@ image: # -- Image pull policy. pullPolicy: Always # -- Image tag. - tag: 0.3.0 + tag: 0.4.0 # -- Secrets to be used for pulling images from private Docker registries. imagePullSecrets: [] diff --git a/package-lock.json b/package-lock.json index f6e8aac..633ddf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "okdp-ui", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "okdp-ui", - "version": "0.3.0", + "version": "0.4.0", "hasInstallScript": true, "dependencies": { "@angular/animations": "^18.1.0", @@ -28,18 +28,24 @@ "@ngrx/store-devtools": "^18.0.1", "@ngx-translate/core": "^15.0.0", "@ngx-translate/http-loader": "^8.0.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "angular-oauth2-oidc": "^17.0.2", "bootstrap": "^5.3.3", "core-js": "^3.37.1", + "date-fns": "^4.1.0", "material-symbols": "^0.24.0", "modern-normalize": "3.0.1", "ngrx-store-localstorage": "^18.0.0", "ngx-bootstrap": "^18.0.1", "ngx-material-luxon": "^1.1.1", "ngx-scrollbar": "^15.1.2", + "ngx-sse-client": "^18.0.0", "ngx-webstorage": "^18.0.0", + "prismjs": "^1.30.0", "rxjs": "^7.8.1", "tslib": "^2.6.3", + "yaml": "^2.8.1", "zone.js": "^0.14.7" }, "devDependencies": { @@ -4365,16 +4371,6 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", @@ -5529,6 +5525,19 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -7485,6 +7494,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -12497,15 +12515,6 @@ "yallist": "^3.0.2" } }, - "node_modules/luxon": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", - "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -13167,6 +13176,18 @@ "rxjs": ">=7.0.0" } }, + "node_modules/ngx-sse-client": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/ngx-sse-client/-/ngx-sse-client-18.0.0.tgz", + "integrity": "sha512-tfwMR5XjehZF7KSYyoY5RwKU8TtCm7rwhr5Rl1EqdMW9+Pw6qj397h5fNXL8XWI0uv1mZkbgN/V2pfsg9u6USQ==", + "dependencies": { + "tslib": "^2.6.3" + }, + "peerDependencies": { + "@angular/common": ">=18.1.2", + "@angular/core": ">=18.1.2" + } + }, "node_modules/ngx-webstorage": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/ngx-webstorage/-/ngx-webstorage-18.0.0.tgz", @@ -13545,6 +13566,15 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oas-linter/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/oas-resolver": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", @@ -13564,6 +13594,15 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oas-resolver/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/oas-schema-walker": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", @@ -13592,6 +13631,15 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oas-validator/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -14598,6 +14646,14 @@ "node": ">=6.0.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "engines": { + "node": ">=6" + } + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -17375,6 +17431,15 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/swagger2openapi/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -19543,12 +19608,14 @@ "dev": true }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 8fcef9c..05e2a28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "okdp-ui", - "version": "0.3.0", - "helmVersion": "0.3.0", + "version": "0.4.0", + "helmVersion": "0.4.0", "scripts": { "ng": "ng", "generate-okdp-api-models": "npx sta -r --no-client -p src/app/api/okdp-api-swagger.json -o src/app/api/_model -n okdp-api.model.ts", @@ -51,7 +51,13 @@ "ngx-material-luxon": "^1.1.1", "ngx-scrollbar": "^15.1.2", "ngx-webstorage": "^18.0.0", + "ngx-sse-client": "^18.0.0", "modern-normalize": "3.0.1", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", + "prismjs": "^1.30.0", + "yaml": "^2.8.1", + "date-fns": "^4.1.0", "rxjs": "^7.8.1", "tslib": "^2.6.3", "zone.js": "^0.14.7" diff --git a/src/app/api/_model/okdp-api.model.ts b/src/app/api/_model/okdp-api.model.ts index 7077216..d94daeb 100644 --- a/src/app/api/_model/okdp-api.model.ts +++ b/src/app/api/_model/okdp-api.model.ts @@ -98,6 +98,64 @@ export interface Package { versions: string[]; } +/** @example {"containers":[{"image":"stefanprodan/podinfo:latest","message":"Back-off 5m0s restarting failed container podinfo","name":"podinfo","reason":"CrashLoopBackOff","state":"Waiting"},{"image":"myorg/sidecar:latest","name":"sidecar","state":"Running"}],"createdAt":"2025-04-25T11:48:48Z","health":"Ready","name":"podinfo-768b456f7c-abc12","namespace":"default","state":"Running"} */ +export interface PodInfo { + /** List of containers in the Pod */ + containers: { + /** + * Container image + * @example "idirze/auth" + */ + image: string; + /** + * Message with additional details, if applicable + * @example "Back-off 5m0s restarting failed container podinfo" + */ + message?: string; + /** + * Container name + * @example "container1" + */ + name: string; + /** + * Reason for the current state, if applicable + * @example "CrashLoopBackOff" + */ + reason?: string; + /** + * Container state + * @example "Running" + */ + state: string; + }[]; + /** + * Pod creation date + * @format date-time + * @example "2025-04-25T11:48:48Z" + */ + createdAt: string; + /** + * Pod health status + * @example "Ready" + */ + health: string; + /** + * Name of the Pod + * @example "podinfo-768b456f7c-abc12" + */ + name: string; + /** + * Namespace in which the Pod is deployed + * @example "default" + */ + namespace: string; + /** + * Pod state + * @example "Running" + */ + state: string; +} + export interface Project { /** * @format date-time diff --git a/src/app/api/okdp-api-swagger.json b/src/app/api/okdp-api-swagger.json index ee26575..d5a26f5 100644 --- a/src/app/api/okdp-api-swagger.json +++ b/src/app/api/okdp-api-swagger.json @@ -327,6 +327,108 @@ "name": "Package" } }, + "PodInfo": { + "example": { + "containers": [ + { + "image": "stefanprodan/podinfo:latest", + "message": "Back-off 5m0s restarting failed container podinfo", + "name": "podinfo", + "reason": "CrashLoopBackOff", + "state": "Waiting" + }, + { + "image": "myorg/sidecar:latest", + "name": "sidecar", + "state": "Running" + } + ], + "createdAt": "2025-04-25T11:48:48Z", + "health": "Ready", + "name": "podinfo-768b456f7c-abc12", + "namespace": "default", + "state": "Running" + }, + "properties": { + "containers": { + "description": "List of containers in the Pod", + "items": { + "properties": { + "image": { + "description": "Container image", + "example": "idirze/auth", + "type": "string" + }, + "message": { + "description": "Message with additional details, if applicable", + "example": "Back-off 5m0s restarting failed container podinfo", + "type": "string" + }, + "name": { + "description": "Container name", + "example": "container1", + "type": "string" + }, + "reason": { + "description": "Reason for the current state, if applicable", + "example": "CrashLoopBackOff", + "type": "string" + }, + "state": { + "description": "Container state", + "example": "Running", + "type": "string" + } + }, + "required": [ + "name", + "image", + "state" + ], + "type": "object" + }, + "type": "array" + }, + "createdAt": { + "description": "Pod creation date", + "example": "2025-04-25T11:48:48Z", + "format": "date-time", + "type": "string" + }, + "health": { + "description": "Pod health status", + "example": "Ready", + "type": "string" + }, + "name": { + "description": "Name of the Pod", + "example": "podinfo-768b456f7c-abc12", + "type": "string" + }, + "namespace": { + "description": "Namespace in which the Pod is deployed", + "example": "default", + "type": "string" + }, + "state": { + "description": "Pod state", + "example": "Running", + "type": "string" + } + }, + "required": [ + "name", + "namespace", + "state", + "createdAt", + "health", + "containers" + ], + "type": "object", + "xml": { + "name": "PodInfo" + } + }, "Project": { "properties": { "creationTimestamp": { @@ -2150,6 +2252,110 @@ ] } }, + "/clusters/{clusterId}/namespaces/{namespace}/pods/{pod}/containers/{container}/logs": { + "get": { + "description": "Streams Kubernetes pod logs using Server-Sent Events (SSE), \nallows downloading them as a plain text file, or \nreturns logs as JSON when requested via Accept=application/json header.\n", + "operationId": "GetLogs", + "parameters": [ + { + "description": "Kubernetes cluster ID", + "in": "path", + "name": "clusterId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Kubernetes namespace", + "in": "path", + "name": "namespace", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Pod name", + "in": "path", + "name": "pod", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Container name", + "in": "path", + "name": "container", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "If true, downloads logs as a plain text file instead of streaming.", + "in": "query", + "name": "download", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Number of log lines to show from the end of the logs.", + "in": "query", + "name": "tailLines", + "schema": { + "default": 100, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "description": "List of log lines returned when SSE is not supported (e.g., Swagger UI).", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "text/event-stream": { + "schema": { + "description": "SSE stream of log lines (each event contains a JSON log entry).", + "type": "string" + } + }, + "text/plain": { + "schema": { + "description": "Complete logs as a downloadable text file.", + "type": "string" + } + } + }, + "description": "Pod logs returned in different formats" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get the logs", + "tags": [ + "pods" + ] + } + }, "/clusters/{clusterId}/namespaces/{namespace}/releases": { "get": { "description": "Get the list of the deployed releases\n", @@ -2469,6 +2675,133 @@ ] } }, + "/clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/events": { + "get": { + "description": "Returns Kubernetes events associated with a KuboCD release as a JSON array.\nThis endpoint mimics the behavior of `kubectl describe release \u003creleaseName\u003e` and is suitable for browser/Swagger usage.\n", + "operationId": "GetEventsRelease", + "parameters": [ + { + "description": "Kubernetes cluster ID", + "in": "path", + "name": "clusterId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Kubernetes namespace", + "in": "path", + "name": "namespace", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "KuboCD release name", + "in": "path", + "name": "releaseName", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "description": "JSON array of Kubernetes event objects for the specified release.\n", + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + } + } + }, + "description": "Returns Kubernetes events for the specified release as a JSON array.\n" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get Kubernetes events for a release", + "tags": [ + "k8s" + ] + } + }, + "/clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/pods": { + "get": { + "description": "Returns all pods in a given namespace and release, including their container names.\n", + "operationId": "GetPods", + "parameters": [ + { + "description": "Kubernetes cluster ID", + "in": "path", + "name": "clusterId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Kubernetes namespace", + "in": "path", + "name": "namespace", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "KuboCD release name", + "in": "path", + "name": "releaseName", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PodInfo" + } + } + }, + "description": "A list of pods and their containers" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get the list of pods and their containers attached to a release", + "tags": [ + "pods" + ] + } + }, "/clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/status": { "get": { "description": "Get the release status\n", @@ -2889,6 +3222,13 @@ "url": "https://github.com/okdp/okdp-server" }, "name": "repositories" + }, + { + "description": "Kubernetes pods", + "externalDocs": { + "url": "https://github.com/okdp/okdp-server" + }, + "name": "pods" } ] } \ No newline at end of file diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 1bb59c4..9ca25c2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -11,12 +11,13 @@ import { APP_ROUTES } from './app.routes'; import { APP_STORE, APP_EFFECTS } from './core/store'; import { HTTP_CLIENT_INTERCEPTORS } from './core/interceptors'; +import { metaReducers } from './core/store/app.storage'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(APP_ROUTES), - provideStore(APP_STORE), + provideStore(APP_STORE, { metaReducers }), provideEffects(APP_EFFECTS), provideAnimations(), provideHttpClient(withInterceptors(HTTP_CLIENT_INTERCEPTORS)), diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b7721dd..a137b6a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -5,17 +5,24 @@ import { ErrorComponent } from './shared/error'; import { LoginComponent } from './core/common/login'; import { AuthGuard } from './core/guards'; import { PageLayoutComponent } from './core/layout/page-layout'; -import { ReleaseListCardComponent, ReleaseCreateUpdateComponent } from './features/releases'; +import { + ReleaseListCardComponent, + ReleaseCreateUpdateComponent, + ReleaseLogComponent, + ReleaseNotificationsComponent, +} from './features/releases'; import { ProjectCreateOrUpdateComponent, ProjectListCardComponent, ProjectListTableComponent, } from './features/projects'; -import { ReleaseListTableComponent } from './features/releases/components/releases-list-table/release-list-table.component'; +import { ReleaseListTableComponent } from './features/releases/components/release-list-table/release-list-table.component'; import { PackageSelectComponent } from './features/releases/components/package-select/package-select.component'; import { StepperComponent } from './shared/components/stepper'; import { ProjectListComponent } from './features/projects/shared'; -import { ReleaseListComponent } from './features/releases/shared'; +import { ReleaseDetailsComponent, ReleaseListComponent } from './features/releases/shared'; +import { ReleaseSummaryComponent } from './features/releases/components/release-summary/release-summary.component'; +import { ReleaseEventComponent } from './features/releases/components/release-events/release-events.component'; export const APP_ROUTES: Routes = [ { path: 'login', component: LoginComponent }, @@ -26,6 +33,7 @@ export const APP_ROUTES: Routes = [ children: [ { path: '', redirectTo: 'projects', pathMatch: 'full' }, { path: 'home', component: HomeComponent }, + // Projects { path: 'projects', component: ProjectListComponent, @@ -42,6 +50,7 @@ export const APP_ROUTES: Routes = [ { path: ':projectName/update', component: ProjectCreateOrUpdateComponent }, ], }, + // Services: Releases + catalogs { path: 'services/:service', component: ReleaseListComponent, @@ -53,9 +62,19 @@ export const APP_ROUTES: Routes = [ ], }, { - path: 'services/:service/instances/:serviceInstance/update', + path: 'services/:service/instances/:release/update', component: ReleaseCreateUpdateComponent, }, + { + path: 'services/:service/instances/:release', + component: ReleaseDetailsComponent, + children: [ + { path: 'summary', component: ReleaseSummaryComponent }, + { path: 'notifications', component: ReleaseNotificationsComponent }, + { path: 'logs', component: ReleaseLogComponent }, + { path: 'events', component: ReleaseEventComponent }, + ], + }, { path: 'services', component: StepperComponent, @@ -68,6 +87,7 @@ export const APP_ROUTES: Routes = [ }, ], }, + // Catalogs: Exploration { path: 'catalogs', children: [ diff --git a/src/app/core/common/catalogs/services/catalog.service.ts b/src/app/core/common/catalogs/services/catalog.service.ts index eeb1e7f..45eabd2 100644 --- a/src/app/core/common/catalogs/services/catalog.service.ts +++ b/src/app/core/common/catalogs/services/catalog.service.ts @@ -25,6 +25,10 @@ export class CatalogService { return this.http.get(`/catalogs/${catalogId}/packages/${name}`); } + getPackageVersion(catalogId: string, name: string, version: string): Observable { + return this.http.get(`/catalogs/${catalogId}/packages/${name}/versions/${version}`); + } + getPackageSchema(catalogId: string, name: string, version: string): Observable { return this.http.get(`/catalogs/${catalogId}/packages/${name}/versions/${version}/schema`); } diff --git a/src/app/core/common/kubocd-releases/services/kubocd-releases.service.ts b/src/app/core/common/kubocd-releases/services/kubocd-releases.service.ts index 744e84d..0c89b2e 100644 --- a/src/app/core/common/kubocd-releases/services/kubocd-releases.service.ts +++ b/src/app/core/common/kubocd-releases/services/kubocd-releases.service.ts @@ -13,10 +13,10 @@ import { EMPTY, timer, } from 'rxjs'; -import { Release } from '../../../../api/_model'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Catalog, Release } from '../../../../api/_model'; import { NotificationService } from '../../notifications'; import { KUBOCD_RELEASES_FETCH_POLLING_INTERVAL_MS } from '../../../constants'; -import { errorMessage } from '../../../models'; import { RELEASE_PHASE_ERROR, RELEASE_PHASE_READY, @@ -26,47 +26,56 @@ import { RELEASE_PHASE_WAIT_HELM_REPO, RELEASE_PHASE_WAIT_OCI, } from '../../../../model'; +import { errorMessage } from '../../../../shared/utils'; +import { CatalogService } from '../../catalogs'; @Injectable({ providedIn: 'root', }) export class KuboCDReleases { private instances: Release[] = []; + private catalogs: Catalog[] = []; constructor( private readonly http: HttpClient, private notificationService: NotificationService, + private catalogService: CatalogService, private destroyRef: DestroyRef - ) {} + ) { + this.catalogService.catalogs$ + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError(error => { + this.notificationService.onError('catalog', '', '', `Unable to fetch catalog, ${errorMessage(error)}`); + return EMPTY; + }) + ) + .subscribe((catalogs: Catalog[]) => { + this.catalogs = catalogs; + }); + } list(clusterId: string, namespace: string): Observable { return this.http.get(`/clusters/${clusterId}/namespaces/${namespace}/releases`); } + getCatalog(release: Release): Catalog | undefined { + const repo = release?.spec?.package?.repository; + if (!repo) return undefined; + const norm = (s: string) => s.replace(/\/+$/g, ''); + return this.catalogs.find(c => c.packages?.some(pkg => `${norm(c.repoUrl)}/${pkg.name}` === norm(repo))); + } + loadKuboCDReleases(clusterId: string, namespaces: string[]): Observable { return forkJoin(namespaces.map(ns => this.list(clusterId, ns))).pipe( map(allReleases => allReleases.flat()), catchError(error => { - this.notificationService.onError('KuboCD', `Failed to fetch kubocd releases, ${errorMessage(error)}`); + this.notificationService.onError('KuboCD', '', '', `Failed to fetch kubocd releases, ${errorMessage(error)}`); return of([]); }) ); } - // loadKuboCDReleases(clusterId: string, namespaces: string[]): void { - // forkJoin(namespaces.map(ns => this.list(clusterId, ns))) - // .pipe(takeUntilDestroyed(this.destroyRef)) - // .subscribe({ - // next: allReleases => { - // const combined = allReleases.flat(); - // this.releases.next(combined); - // //this.updateInstances(combined, false); - // }, - // error: error => - // this.notificationService.onError('KuboCD', `Failed to fetch kubocd releases, ${errorMessage(error)}`), - // }); - // } - startPollServicesChange(clusterId: string, namespaces: string[]): Observable { return concat(EMPTY, timer(0), interval(KUBOCD_RELEASES_FETCH_POLLING_INTERVAL_MS)).pipe( switchMap(() => @@ -76,6 +85,8 @@ export class KuboCDReleases { catchError(error => { this.notificationService.onError( 'KuboCD', + '', + '', `Failed to poll kubocd releases from namespace "${ns}", ${errorMessage(error)}` ); return of([]); @@ -98,38 +109,88 @@ export class KuboCDReleases { const name = s.metadata.name; const namespace = s.metadata.namespace; const phase = s.status?.phase?.toUpperCase() ?? 'UNKNOWN'; + const creationTimestamp = s.metadata.creationTimestamp; + const catalogId = this.getCatalog(s)?.id || ''; switch (phase) { case RELEASE_PHASE_READY: - this.notificationService.onSuccess(`${name}/${namespace}`, 'was successfully deployed.'); + this.notificationService.onSuccess( + `${name}`, + `${namespace}`, + catalogId, + 'was successfully deployed.', + creationTimestamp + ); break; case RELEASE_PHASE_ERROR: - this.notificationService.onError(`${name}/${namespace}`, 'was failed to deploy.'); + this.notificationService.onError( + `${name}`, + `${namespace}`, + catalogId, + 'was failed to deploy.', + creationTimestamp + ); break; case RELEASE_PHASE_WAIT_OCI: - this.notificationService.onInfo(`${name}/${namespace}`, 'is waiting for OCI.'); + this.notificationService.onInfo( + `${name}`, + `${namespace}`, + catalogId, + 'is waiting for OCI.', + creationTimestamp + ); break; case RELEASE_PHASE_WAIT_HELM_REPO: - this.notificationService.onInfo(`${name}/${namespace}`, 'is waiting for Helm repository.'); + this.notificationService.onInfo( + `${name}`, + `${namespace}`, + catalogId, + 'is waiting for Helm repository.', + creationTimestamp + ); break; case RELEASE_PHASE_WAIT_HELM_RELEASES: - this.notificationService.onInfo(`${name}/${namespace}`, 'is waiting for helm release deplyment.'); + this.notificationService.onInfo( + `${name}`, + `${namespace}`, + catalogId, + 'is waiting for helm release deplyment.', + creationTimestamp + ); break; case RELEASE_PHASE_WAIT_DEPENDENCIES: - this.notificationService.onInfo(`${name}/${namespace}`, 'is waiting for dependencies.'); + this.notificationService.onInfo( + `${name}`, + `${namespace}`, + catalogId, + 'is waiting for dependencies.', + creationTimestamp + ); break; case RELEASE_PHASE_SUSPENDED: - this.notificationService.onWarning(`${name}/${namespace}`, 'is suspended.'); + this.notificationService.onWarning( + `${name}`, + `${namespace}`, + catalogId, + 'is suspended.', + creationTimestamp + ); break; case undefined: default: - this.notificationService.onWarning(`${name}/${namespace}`, 'Unknown status phase.'); + this.notificationService.onWarning( + `${name}`, + `${namespace}`, + catalogId, + 'Unknown status phase.', + creationTimestamp + ); } }); } if (deletedInstances.length > 0) { deletedInstances.forEach(s => - this.notificationService.onWarning(`${s.metadata.name}/${s.metadata.namespace}`, 'was removed !') + this.notificationService.onWarning(`${s.metadata.name}`, `${s.metadata.namespace}`, '', 'was removed !') ); } } @@ -177,7 +238,7 @@ export class KuboCDReleases { }; } - clear(): void { + clearAll(): void { this.instances = []; } } diff --git a/src/app/core/common/notifications/components/notification.component.html b/src/app/core/common/notifications/components/notification.component.html index 54b3fee..6613f3b 100644 --- a/src/app/core/common/notifications/components/notification.component.html +++ b/src/app/core/common/notifications/components/notification.component.html @@ -7,12 +7,24 @@ class="offcanvas offcanvas-end notifications show {{ isToggled ? 'slide-in' : 'slide-out' }}"> -
-
-
+
+
+
-
Notifications
+
Notifications
+
+
+
+ +
+
-
-
-
+
+
+
-
- +
+
+ {{ notification.creationTimestamp | timeAgo }} + +
-
-
- {{ getClass(notification.type).icon }} -
-
-

- {{ notification.service }} + +

+ + {{ cls.icon }} + + +

+ + {{ notification.service }} + {{ notification.message }}

-
+
diff --git a/src/app/core/common/notifications/components/notification.component.scss b/src/app/core/common/notifications/components/notification.component.scss index e69de29..547a3d3 100644 --- a/src/app/core/common/notifications/components/notification.component.scss +++ b/src/app/core/common/notifications/components/notification.component.scss @@ -0,0 +1,4 @@ +.clear-label { + padding-right: 2rem; // adjust as needed + display: inline-block; +} \ No newline at end of file diff --git a/src/app/core/common/notifications/components/notification.component.ts b/src/app/core/common/notifications/components/notification.component.ts index 744bcd7..70a2761 100644 --- a/src/app/core/common/notifications/components/notification.component.ts +++ b/src/app/core/common/notifications/components/notification.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { combineLatest, filter, switchMap, tap } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; +import { Router } from '@angular/router'; import { RightSidebarToggle, RightSidebarService } from '../../../../shared/services'; import { Notification, NotificationType } from '../../../models'; import { NotificationService } from '../services/notification.service'; @@ -10,11 +11,15 @@ import { KuboCDReleases } from '../../kubocd-releases'; import { getClusterId } from '../../clusters'; import { AppState } from '../../../store'; import { getProjectName } from '../../projects'; +import { SearchFilterComponent } from '../../../../shared/components/search-filter'; +import { getClass } from '../../../../shared/utils/notification'; +import { TimeAgoPipe } from '../../../../shared/pipes'; +import { Catalog } from '../../../../api/_model'; @Component({ selector: 'app-notifications', standalone: true, - imports: [CommonModule], + imports: [CommonModule, SearchFilterComponent, TimeAgoPipe], templateUrl: './notification.component.html', styleUrls: ['./notification.component.scss'], animations: [], @@ -22,12 +27,18 @@ import { getProjectName } from '../../projects'; export class NotificationComponent implements OnInit { isToggled = false; - notifications: Notification[] = []; + allNotifications: Notification[] = []; + filtredItems: Notification[] = []; + + catalogs: Catalog[] = []; + + search = ''; constructor( private notificationService: NotificationService, private rightSidebarService: RightSidebarService, private kubocdReleases: KuboCDReleases, + private router: Router, private store: Store, private destroyRef: DestroyRef ) {} @@ -37,8 +48,8 @@ export class NotificationComponent implements OnInit { .pipe( filter(([clusterId, projectName]) => !!clusterId && !!projectName), tap(() => { - this.kubocdReleases.clear(); - this.notificationService.clear(); + this.kubocdReleases.clearAll(); + this.notificationService.clearAll(); }), switchMap(([clusterId, projectName]) => this.kubocdReleases.startPollServicesChange(clusterId, [projectName])), takeUntilDestroyed(this.destroyRef) @@ -55,34 +66,52 @@ export class NotificationComponent implements OnInit { .subscribe(event => (this.isToggled = event.isToggle)); this.notificationService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(notifications => { - this.notifications = notifications; + this.allNotifications = notifications; + this.filtredItems = notifications; }); } - onClose(service: string) { - this.notificationService.remove(service); - } - - onClear() { - this.notificationService.clear(); + onClose(service: string, project: string) { + this.notificationService.remove(service, project); } getClass(type: NotificationType): { msg: string; icon: string } { - switch (type) { - case NotificationType.Success: - return { msg: 'text-success', icon: 'check_circle' }; - case NotificationType.Error: - return { msg: 'text-danger', icon: 'error' }; - case NotificationType.Info: - return { msg: 'text-info', icon: 'info' }; - case NotificationType.Warning: - return { msg: 'text-warning', icon: 'warning' }; - default: - return { msg: 'text-warning', icon: 'warning' }; - } + return getClass(type); } hideSidebar() { this.rightSidebarService.toggle(RightSidebarToggle.NOTIFICATION); } + + onSearchChanged(search: string): void { + this.search = (search ?? '').trim().toLowerCase(); + if (!this.search) { + this.filtredItems = this.allNotifications; + } else { + const keywords = this.search + .toLowerCase() + .split(' ') + .filter(k => k); + this.filtredItems = this.allNotifications.filter(n => + keywords.some( + keyword => + n.message.toLowerCase().includes(keyword) || + n.type.toLowerCase().includes(keyword) || + n.service.toLowerCase().includes(keyword) + ) + ); + } + } + + onClick(notification: Notification): void { + this.router.navigate([`/services/${notification.catalogId}/instances/${notification.service}/summary`]); + } + + get notifications() { + return [...this.filtredItems].sort((a, b) => b.creationTimestamp.localeCompare(a.creationTimestamp)); + } + + onClear() { + this.notificationService.clear(this.filtredItems); + } } diff --git a/src/app/core/common/notifications/services/notification.service.ts b/src/app/core/common/notifications/services/notification.service.ts index 1287f8d..e4517a2 100644 --- a/src/app/core/common/notifications/services/notification.service.ts +++ b/src/app/core/common/notifications/services/notification.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { Notification, NotificationType } from '../../../models'; +import { nowIsoString } from '../../../constants'; @Injectable({ providedIn: 'root', @@ -12,32 +13,63 @@ export class NotificationService { constructor() {} - onSuccess(service: string, message: string): void { - this.addMessage(service, NotificationType.Success, message); + onSuccess( + service: string, + project: string, + catalogId: string, + message: string, + creationTimestamp: string = nowIsoString() + ): void { + this.addMessage(service, project, NotificationType.Success, catalogId, message, creationTimestamp); } - onError(service: string, message: string): void { - this.addMessage(service, NotificationType.Error, message); + onError( + service: string, + project: string, + catalogId: string, + message: string, + creationTimestamp: string = nowIsoString() + ): void { + this.addMessage(service, project, NotificationType.Error, catalogId, message, creationTimestamp); } - onInfo(service: string, message: string): void { - this.addMessage(service, NotificationType.Info, message); + onInfo( + service: string, + project: string, + catalogId: string, + message: string, + creationTimestamp: string = nowIsoString() + ): void { + this.addMessage(service, project, NotificationType.Info, catalogId, message, creationTimestamp); } - onWarning(service: string, message: string): void { - this.addMessage(service, NotificationType.Warning, message); + onWarning( + service: string, + project: string, + catalogId: string, + message: string, + creationTimestamp: string = nowIsoString() + ): void { + this.addMessage(service, project, NotificationType.Warning, catalogId, message, creationTimestamp); } - private addMessage(service: string, type: NotificationType, message: string): void { - const newMessage: Notification = { type, service, message }; - this.notifications.set(service, newMessage); + private addMessage( + service: string, + project: string, + type: NotificationType, + catalogId: string, + message: string, + creationTimestamp: string + ): void { + const newMessage: Notification = { type, service, project, catalogId, message, creationTimestamp }; + this.notifications.set(`${service}/${project}`, newMessage); this.messagesSubject.next([...this.notifications.values()]); //setTimeout(() => this.removeMessage(service), NOTIFICATION_MESSAGE_VISIBILITY_TIMEOUT); } - updateMessage(service: string, message: string): void { - if (this.notifications.has(service)) { - const existingMessage = this.notifications.get(service); + updateMessage(service: string, project: string, message: string): void { + if (this.notifications.has(`${service}/${project}`)) { + const existingMessage = this.notifications.get(`${service}/${project}`); if (existingMessage) { existingMessage.message = message; this.messagesSubject.next([...this.notifications.values()]); @@ -45,13 +77,24 @@ export class NotificationService { } } - remove(service: string): void { - this.notifications.delete(service); + remove(service: string, project: string): void { + this.notifications.delete(`${service}/${project}`); this.messagesSubject.next([...this.notifications.values()]); } - clear(): void { + clearAll(): void { this.notifications.clear(); this.messagesSubject.next([]); } + + clear(filtredNotifications: Notification[]): void { + if (!Array.isArray(filtredNotifications) || filtredNotifications.length === 0) return; + + for (const n of filtredNotifications) { + if (!n) continue; + this.notifications.delete(`${n.service}/${n.project}`); + } + + this.messagesSubject.next([...this.notifications.values()]); + } } diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index a669da3..dec9c53 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -5,4 +5,7 @@ export const CATALOG_URI = 'catalogs'; export const KUBOCD_RELEASES_FETCH_POLLING_INTERVAL_MS = 30 * 1000; export const NOTIFICATION_MESSAGE_VISIBILITY_TIMEOUT_MS = 60 * 1000; export const KUBERNETES_OBJECT_PATTERN = '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'; -export const REGISTRY_REPO_URL_PATTERN = /\/([^\/:]+)(?=(:[^\/]*)?$)/; +export const REGISTRY_REPO_URL_PATTERN = /\/([^/:]+)(?=(:[^/]*)?$)/; + +// Helper functions +export const nowIsoString = (): string => new Date().toISOString(); diff --git a/src/app/core/layout/sidebar/components/sidebar.component.ts b/src/app/core/layout/sidebar/components/sidebar.component.ts index 82085fa..1746201 100644 --- a/src/app/core/layout/sidebar/components/sidebar.component.ts +++ b/src/app/core/layout/sidebar/components/sidebar.component.ts @@ -11,12 +11,12 @@ import { LoadingComponent } from '../../../../shared/components/loading'; import { TitleCasePipe } from '../../../../shared/pipes'; import { Catalog } from '../../../../api/_model'; import { NotificationService } from '../../../common/notifications'; -import { errorMessage } from '../../../models'; import { SidebarService } from '../services/sidebar.service'; import { AppConfigService } from '../../../config'; import { CatalogService } from '../../../common/catalogs'; import { LayoutService } from '../../../../shared/services'; import { getProjectName } from '../../../common/projects'; +import { errorMessage } from '../../../../shared/utils'; @Component({ selector: 'app-sidebar', @@ -89,7 +89,7 @@ export class SidebarComponent implements OnInit { }); }, error: error => { - this.notificationService.onError('cluster', `Unable to fetch catalog, ${errorMessage(error)}`); + this.notificationService.onError('cluster', '', '', `Unable to fetch catalog, ${errorMessage(error)}`); }, }); } diff --git a/src/app/core/models/notification.model.ts b/src/app/core/models/notification.model.ts index 27e3d5d..26b32d9 100644 --- a/src/app/core/models/notification.model.ts +++ b/src/app/core/models/notification.model.ts @@ -8,9 +8,8 @@ export enum NotificationType { export interface Notification { type: NotificationType; service: string; + project: string; + catalogId: string; message: string; -} - -export function errorMessage(error: Error): string { - return error?.message || 'Unknown error occurred'; + creationTimestamp: string; } diff --git a/src/app/core/store/app.storage.ts b/src/app/core/store/app.storage.ts new file mode 100644 index 0000000..1540615 --- /dev/null +++ b/src/app/core/store/app.storage.ts @@ -0,0 +1,13 @@ +// app.module.ts or store module +import { MetaReducer } from '@ngrx/store'; +import { localStorageSync } from 'ngrx-store-localstorage'; + +export function localStorageSyncReducer(reducer: any): any { + return localStorageSync({ + keys: ['cluster', 'project'], + storage: localStorage, + rehydrate: true, + })(reducer); +} + +export const metaReducers: MetaReducer[] = [localStorageSyncReducer]; diff --git a/src/app/features/catalogs/components/catalog-list-packages/catalog-list-packages.component.ts b/src/app/features/catalogs/components/catalog-list-packages/catalog-list-packages.component.ts index 670cd27..e303b99 100644 --- a/src/app/features/catalogs/components/catalog-list-packages/catalog-list-packages.component.ts +++ b/src/app/features/catalogs/components/catalog-list-packages/catalog-list-packages.component.ts @@ -13,14 +13,13 @@ import { NavTabsComponent } from '../../../../shared/components/nav-tabs'; import { CatalogService } from '../../../../core/common/catalogs'; import { Catalog } from '../../../../api/_model'; import { CATALOG_URI } from '../../../../core/constants'; -import { toUri } from '../../../../shared/utils'; +import { errorMessage, toUri } from '../../../../shared/utils'; import { CatalogItem } from '../../../../model/catalog.model'; import { AppConfigService } from '../../../../core/config'; import { LoadingComponent } from '../../../../shared/components/loading'; import { TitleBarService } from '../../../../shared/components/content-header-title'; import { SidebarService } from '../../../../core/layout/sidebar'; import { NotificationService } from '../../../../core/common/notifications'; -import { errorMessage } from '../../../../core/models'; import { KebabMenuComponent } from '../../../../shared/components/kebab-menu'; import { ContentToolbarComponent } from '../../../../shared/components/content-toolbar'; @@ -95,7 +94,7 @@ export class CatalogListPackagesComponent implements OnInit { this.searchChanged(search); }, error: error => { - this.notificationService.onError('search', `Search error, ${errorMessage(error)}`); + this.notificationService.onError('search', '', '', `Search error, ${errorMessage(error)}`); }, }); } @@ -113,7 +112,7 @@ export class CatalogListPackagesComponent implements OnInit { this.toCurrentCatalogCatalog(); }, error: error => { - this.notificationService.onError('catalog', `Unable to fetch catalog, ${errorMessage(error)}`); + this.notificationService.onError('catalog', '', '', `Unable to fetch catalog, ${errorMessage(error)}`); }, }); } diff --git a/src/app/features/projects/components/projects-create-update/projects-create-update.component.ts b/src/app/features/projects/components/projects-create-update/projects-create-update.component.ts index a90850e..90f51c3 100644 --- a/src/app/features/projects/components/projects-create-update/projects-create-update.component.ts +++ b/src/app/features/projects/components/projects-create-update/projects-create-update.component.ts @@ -17,7 +17,7 @@ import { LoadingComponent } from '../../../../shared/components/loading'; import { Project, ServerResponse } from '../../../../api/_model'; import { KUBERNETES_OBJECT_PATTERN } from '../../../../core/constants'; import { getClusterId } from '../../../../core/common/clusters'; -import { errorMessage } from '../../../../core/models'; +import { errorMessage } from '../../../../shared/utils'; @Component({ selector: 'app-project-create-update', @@ -105,6 +105,8 @@ export class ProjectCreateOrUpdateComponent implements OnInit { next: (_: ServerResponse) => { this.notificationService.onSuccess( `${this.projectPayload.name}/${this.projectPayload.displayName}`, + '', + '', `project ${action}d successfully.` ); this.isSubmitting = false; @@ -113,6 +115,8 @@ export class ProjectCreateOrUpdateComponent implements OnInit { error: error => { this.notificationService.onError( `${this.projectPayload.name}/${this.projectPayload.displayName}`, + '', + '', `project was failed to ${action}, ${errorMessage(error)}` ); this.isSubmitting = false; @@ -149,7 +153,12 @@ export class ProjectCreateOrUpdateComponent implements OnInit { this.populateForm(); }, error: error => { - this.notificationService.onError(`${projectName}`, `project was failed to load, ${errorMessage(error)}`); + this.notificationService.onError( + `${projectName}`, + '', + '', + `project was failed to load, ${errorMessage(error)}` + ); }, }); } diff --git a/src/app/features/projects/shared/components/project-base/project-base-component.ts b/src/app/features/projects/shared/components/project-base/project-base-component.ts index 84d0ba2..646279e 100644 --- a/src/app/features/projects/shared/components/project-base/project-base-component.ts +++ b/src/app/features/projects/shared/components/project-base/project-base-component.ts @@ -19,10 +19,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NotificationService } from '../../../../../core/common/notifications'; import { getProjectName, loadProjectFailure, ProjectService, selectProject } from '../../../../../core/common/projects'; import { Project, ServerResponse } from '../../../../../api/_model'; -import { errorMessage } from '../../../../../core/models'; import { AppState } from '../../../../../core/store'; import { getClusterId } from '../../../../../core/common/clusters'; import { SearchFilterService } from '../../../../../shared/components/search-filter'; +import { errorMessage } from '../../../../../shared/utils'; @Injectable() export abstract class AbstractProjectBaseComponent { @@ -73,7 +73,7 @@ export abstract class AbstractProjectBaseComponent { this.isLoaded = true; }), catchError(err => { - this.notificationService.onError('Projects', `Failed to load projects: ${err.message || err}`); + this.notificationService.onError('Projects', '', '', `Failed to load projects: ${err.message || err}`); this.isLoaded = false; return EMPTY; }) @@ -85,7 +85,7 @@ export abstract class AbstractProjectBaseComponent { this.searchChanged(search); }, error: error => { - this.notificationService.onError('search', `Search error, ${errorMessage(error)}`); + this.notificationService.onError('search', '', '', `Search error, ${errorMessage(error)}`); }, }); } @@ -158,10 +158,10 @@ export abstract class AbstractProjectBaseComponent { filter(project => !project), take(1), tap(() => { - this.notificationService.onSuccess('Projects', `Project "${projectName}" deleted.`); + this.notificationService.onSuccess('Projects', '', '', `Project "${projectName}" deleted.`); }), catchError(error => { - this.notificationService.onError('Projects', `Failed during project polling: ${error.message}`); + this.notificationService.onError('Projects', '', '', `Failed during project polling: ${error.message}`); return of(null); }) ) @@ -174,10 +174,10 @@ export abstract class AbstractProjectBaseComponent { protected deleteProject(clusterId: string, projectName: string): Observable { return this.projectService.delete(clusterId, projectName).pipe( tap(() => { - this.notificationService.onSuccess(projectName, 'Project deleted successfully.'); + this.notificationService.onSuccess(projectName, '', '', 'Project deleted successfully.'); }), catchError(error => { - this.notificationService.onError(projectName, `Failed to delete project, ${errorMessage(error)}`); + this.notificationService.onError(projectName, '', '', `Failed to delete project, ${errorMessage(error)}`); return throwError(() => error); }) ); diff --git a/src/app/features/releases/components/package-select/package-select.component.ts b/src/app/features/releases/components/package-select/package-select.component.ts index 40ca91e..4b47845 100644 --- a/src/app/features/releases/components/package-select/package-select.component.ts +++ b/src/app/features/releases/components/package-select/package-select.component.ts @@ -13,9 +13,9 @@ import { Catalog } from '../../../../api/_model'; import { AppConfigService } from '../../../../core/config'; import { LoadingComponent } from '../../../../shared/components/loading'; import { NotificationService } from '../../../../core/common/notifications'; -import { errorMessage } from '../../../../core/models'; import { KebabMenuComponent } from '../../../../shared/components/kebab-menu'; import { CatalogItem } from '../../../../model'; +import { errorMessage } from '../../../../shared/utils'; @Component({ selector: 'app-package-select', @@ -88,7 +88,7 @@ export class PackageSelectComponent implements OnInit { this.toCurrentCatalogCatalog(); }, error: error => { - this.notificationService.onError('catalog', `Unable to fetch catalog, ${errorMessage(error)}`); + this.notificationService.onError('catalog', '', '', `Unable to fetch catalog, ${errorMessage(error)}`); }, }); } diff --git a/src/app/features/releases/components/release-create-update/release-create-update.component.ts b/src/app/features/releases/components/release-create-update/release-create-update.component.ts index 4650490..f76a6a2 100644 --- a/src/app/features/releases/components/release-create-update/release-create-update.component.ts +++ b/src/app/features/releases/components/release-create-update/release-create-update.component.ts @@ -23,7 +23,7 @@ import { select, Store } from '@ngrx/store'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { combineLatest, filter, firstValueFrom, tap } from 'rxjs'; import { AuthService } from '../../../../core/auth'; -import { errorMessage, UserInfo } from '../../../../core/models'; +import { UserInfo } from '../../../../core/models'; import { RightSidebarService, RightSidebarToggle } from '../../../../shared/services'; import { sortVersionsDesc, @@ -32,6 +32,7 @@ import { JsonSchemaProperty, deflateParameters, findValueDeep, + errorMessage, } from '../../../../shared/utils'; import { GitReleaseService } from '../../services/git-release.service'; import { K8sReleaseService } from '../../services/k8s-release.service'; @@ -86,7 +87,7 @@ export class ReleaseCreateUpdateComponent implements OnInit { currentProjectName: string; readonly userInfo: UserInfo; submissionMode: string; - serviceInstance: string; + release: string; separatorKeysCodes: number[] = [ENTER, COMMA]; @@ -138,7 +139,7 @@ export class ReleaseCreateUpdateComponent implements OnInit { this.catalogItem.catalogId = this.route.snapshot.queryParamMap.get('catalog') as string; this.catalogItem.icon = this.appConfigService.kadServicesInfo(this.catalogItem.name).icon as string; this.catalogItem.home = this.appConfigService.kadServicesInfo(this.catalogItem.name).home as string; - this.serviceInstance = this.route.snapshot.paramMap.get('serviceInstance') as string; + this.release = this.route.snapshot.paramMap.get('release') as string; this.submissionMode = this.appConfigService.getSubmissionMode(); @@ -182,7 +183,7 @@ export class ReleaseCreateUpdateComponent implements OnInit { } get isCreate(): boolean { - return !this.serviceInstance?.trim(); + return !this.release?.trim(); } fetchPackage(catalogId: string, name: string): void { @@ -198,7 +199,7 @@ export class ReleaseCreateUpdateComponent implements OnInit { this.isLoaded = true; }, error: error => { - this.notificationService.onError(name, `Unable to fetch service, ${errorMessage(error)}`); + this.notificationService.onError(name, '', '', `Unable to fetch service, ${errorMessage(error)}`); }, }); } @@ -213,7 +214,7 @@ export class ReleaseCreateUpdateComponent implements OnInit { this.releasePayload.spec.parameters = deflated; const namespace = this.currentProjectName; - const name = this.releasePayload.metadata.name; + const name = this.releasePayload.metadata.name!; this.releasePayload.spec.targetNamespace = namespace; const handlers = { @@ -234,13 +235,15 @@ export class ReleaseCreateUpdateComponent implements OnInit { .subscribe({ next: (_: ServerResponse) => { this.notificationService.onSuccess( - `${name}/${namespace}`, + name, + namespace, + '', `was successfully submitted into ${this.submissionMode === 'git' ? 'Git' : 'Kubernetes'}.` ); this.goBack(); }, error: error => { - this.notificationService.onError(`${name}/${namespace}`, `was failed, ${errorMessage(error)}`); + this.notificationService.onError(name, namespace, '', `was failed, ${errorMessage(error)}`); this.goBack(); }, }); @@ -371,11 +374,13 @@ export class ReleaseCreateUpdateComponent implements OnInit { const message = error instanceof Error ? errorMessage(error) : 'Unknown error'; this.notificationService.onError( 'catalog', + '', + '', `Unable to fetch package ${packageName}:${tag} parameters from catalogId ${catalogId}, ${message}` ); } - if (this.serviceInstance) { - this.loadReleaseForUpdate(this.clusterId, this.currentProjectName, this.serviceInstance); + if (this.release) { + this.loadReleaseForUpdate(this.clusterId, this.currentProjectName, this.release); } this.cdr.detectChanges(); } @@ -417,7 +422,9 @@ export class ReleaseCreateUpdateComponent implements OnInit { }, error: error => { this.notificationService.onError( - `${projectName}/${releaseName}`, + releaseName, + projectName, + '', `Release was failed to load, ${errorMessage(error)}` ); }, diff --git a/src/app/features/releases/components/release-events/release-events.component.html b/src/app/features/releases/components/release-events/release-events.component.html new file mode 100644 index 0000000..a68f4ee --- /dev/null +++ b/src/app/features/releases/components/release-events/release-events.component.html @@ -0,0 +1,50 @@ +
+ +
+ +
+
+
+
+
+
Occured At
+
Reason
+
Message
+
Count
+
+
+
+
+
+
+
+
+
+
+ + {{ event.lastTimestamp || event.firstTimestamp | date:'yyyy-MM-dd HH:mm:ss' }} + +
+
+ {{ event.reason }} +
+
+ {{ event.message }} +
+
+ {{ event.count }} +
+
+
+
+
+
+ +
+ + info + No events to display. + +
+
+
diff --git a/src/app/features/releases/components/release-events/release-events.component.scss b/src/app/features/releases/components/release-events/release-events.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/releases/components/release-events/release-events.component.ts b/src/app/features/releases/components/release-events/release-events.component.ts new file mode 100644 index 0000000..5f009b3 --- /dev/null +++ b/src/app/features/releases/components/release-events/release-events.component.ts @@ -0,0 +1,41 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { LoadingComponent } from '../../../../shared/components/loading'; +import { K8sEvent, ReleaseInstance } from '../../../../model'; +import { ReleaseEventsService } from '../../services/release-events.service'; +import { AbstractReleaseInstanceComponent } from '../../shared/components/release-instance/release-instance.component'; + +@Component({ + selector: 'app-release-events', + standalone: true, + imports: [CommonModule, LoadingComponent], + templateUrl: './release-events.component.html', + styleUrls: ['./release-events.component.scss'], + animations: [], +}) +export class ReleaseEventComponent extends AbstractReleaseInstanceComponent implements OnInit { + events: K8sEvent[] = []; + + constructor(private releaseEventsService: ReleaseEventsService) { + super(); + } + + override updateDataSourceForInstance(instance: ReleaseInstance): void { + const clusterId = this.currentClusterId; + const namespace = instance.metadata.namespace!; + const releaseName = instance.metadata.name!; + + this.releaseEventsService + .list(clusterId, namespace, releaseName) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: events => { + this.events = events || []; + }, + error: () => { + this.events = []; + }, + }); + } +} diff --git a/src/app/features/releases/components/releases-list-card/release-list-card.component.html b/src/app/features/releases/components/release-list-card/release-list-card.component.html similarity index 98% rename from src/app/features/releases/components/releases-list-card/release-list-card.component.html rename to src/app/features/releases/components/release-list-card/release-list-card.component.html index c35a6cf..134bb97 100644 --- a/src/app/features/releases/components/releases-list-card/release-list-card.component.html +++ b/src/app/features/releases/components/release-list-card/release-list-card.component.html @@ -68,6 +68,7 @@ role="button" class="material-symbols-outlined text-okdp-primary cursor-pointer" matTooltip="Show details" + (click)="onShowDetails(instance)" >visibility @@ -92,8 +93,7 @@
diff --git a/src/app/features/releases/components/releases-list-card/release-list-card.component.scss b/src/app/features/releases/components/release-list-card/release-list-card.component.scss similarity index 100% rename from src/app/features/releases/components/releases-list-card/release-list-card.component.scss rename to src/app/features/releases/components/release-list-card/release-list-card.component.scss diff --git a/src/app/features/releases/components/releases-list-card/release-list-card.component.ts b/src/app/features/releases/components/release-list-card/release-list-card.component.ts similarity index 87% rename from src/app/features/releases/components/releases-list-card/release-list-card.component.ts rename to src/app/features/releases/components/release-list-card/release-list-card.component.ts index 4541781..e60b7dd 100644 --- a/src/app/features/releases/components/releases-list-card/release-list-card.component.ts +++ b/src/app/features/releases/components/release-list-card/release-list-card.component.ts @@ -8,10 +8,10 @@ import { ReleaseInstance } from '../../../../model'; import { ContentToolbarComponent } from '../../../../shared/components/content-toolbar'; import { AbstractReleaseBaseComponent } from '../../shared'; import { DialogComponent } from '../../../../shared/components/dialog'; -import { extractService } from '../../../../shared/utils'; +import { extractPackage } from '../../../../shared/utils'; @Component({ - selector: 'app-releases-list-card', + selector: 'app-release-list-card', standalone: true, imports: [ CommonModule, @@ -32,10 +32,6 @@ export class ReleaseListCardComponent extends AbstractReleaseBaseComponent imple super(); } - ngOnInit(): void { - super.onInit(); - } - highlightMatch(item: string | undefined): string | undefined { if (!this.search) return item; const query = this.search; @@ -50,8 +46,12 @@ export class ReleaseListCardComponent extends AbstractReleaseBaseComponent imple } onEdit(instance: ReleaseInstance) { - super.edit(extractService(instance.spec.package.repository), instance.metadata.name!); + super.edit(extractPackage(instance.spec.package.repository), instance.metadata.name!); } onFavorite(row: ReleaseInstance) {} + + onShowDetails(instance: ReleaseInstance) { + super.showDetails(instance.metadata.name!); + } } diff --git a/src/app/features/releases/components/releases-list-table/release-list-table.component.html b/src/app/features/releases/components/release-list-table/release-list-table.component.html similarity index 98% rename from src/app/features/releases/components/releases-list-table/release-list-table.component.html rename to src/app/features/releases/components/release-list-table/release-list-table.component.html index 4ee0cf3..d27fc91 100644 --- a/src/app/features/releases/components/releases-list-table/release-list-table.component.html +++ b/src/app/features/releases/components/release-list-table/release-list-table.component.html @@ -90,8 +90,9 @@ role="button" class="material-symbols-outlined text-okdp-primary cursor-pointer" matTooltip="Show details" - >visibility + (click)="onShowDetails(row)"> + visibility +
+ +
+
+ + + +
+ + +
+ + info + No logs to display. + +
+
+
diff --git a/src/app/features/releases/components/release-log/release-log.component.scss b/src/app/features/releases/components/release-log/release-log.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/releases/components/release-log/release-log.component.ts b/src/app/features/releases/components/release-log/release-log.component.ts new file mode 100644 index 0000000..1c72480 --- /dev/null +++ b/src/app/features/releases/components/release-log/release-log.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EndpointsFromUsagePipe } from '../../../../shared/pipes'; +import { LoadingComponent } from '../../../../shared/components/loading'; +import { LogTerminalComponent } from '../../../../shared/components/log-terminal'; +import { AbstractReleaseInstanceComponent } from '../../shared/components/release-instance/release-instance.component'; + +@Component({ + selector: 'app-release-log', + standalone: true, + imports: [CommonModule, LoadingComponent, LogTerminalComponent], + providers: [EndpointsFromUsagePipe], + templateUrl: './release-log.component.html', + styleUrls: ['./release-log.component.scss'], + animations: [], +}) +export class ReleaseLogComponent extends AbstractReleaseInstanceComponent implements OnInit { + private qpPodName?: string; + private qpContainerName?: string; + + constructor() { + super(); + } + + override ngOnInit(): void { + super.ngOnInit(); + this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(qp => { + this.qpPodName = qp.get('pod') ?? undefined; + this.qpContainerName = qp.get('container') ?? undefined; + }); + } + + /** + * Effective pod name to display logs for: + * - Use query param `pod` if present AND exists in pods list + * - Otherwise fall back to the first pod (current behavior) + */ + get selectedPodName(): string | undefined { + const podFromQP = this.qpPodName && this.pods.find(p => p.name === this.qpPodName)?.name; + return podFromQP ?? this.pods[0]?.name; + } + + /** + * Effective container name to display logs for: + * - If query param `container` is present AND exists on the selected pod, use it + * - Otherwise fall back to the first container of the selected pod (current behavior) + */ + get selectedContainerName(): string | undefined { + const podName = this.selectedPodName; + const pod = this.pods.find(p => p.name === podName); + if (!pod) return undefined; + + const containerFromQP = this.qpContainerName && pod.containers?.find(c => c.name === this.qpContainerName)?.name; + + return containerFromQP ?? pod.containers?.[0]?.name; + } + + /** + * Build the log URL from the effective pod/container. + * Returns empty string when not resolvable (no pods yet, etc.) + */ + getLogUrl(): string { + const podName = this.selectedPodName; + const containerName = this.selectedContainerName; + if (podName && containerName) { + return `/clusters/${this.currentClusterId}/namespaces/${this.currentProjectName}/pods/${podName}/containers/${containerName}/logs`; + } + return ''; + } +} diff --git a/src/app/features/releases/components/release-notifications/release-notifications.component.html b/src/app/features/releases/components/release-notifications/release-notifications.component.html new file mode 100644 index 0000000..d6d75f8 --- /dev/null +++ b/src/app/features/releases/components/release-notifications/release-notifications.component.html @@ -0,0 +1,36 @@ +
+ +
+
+
+
+
+
+
+
+
+ + {{ getClass(notification.type).icon }} + +
+
+

+ {{ notification.service }} + {{ notification.message }} +

+
+
+
+
+
+
+
+ +
+ + info + No notifications to display. + +
+
+
diff --git a/src/app/features/releases/components/release-notifications/release-notifications.component.scss b/src/app/features/releases/components/release-notifications/release-notifications.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/releases/components/release-notifications/release-notifications.component.ts b/src/app/features/releases/components/release-notifications/release-notifications.component.ts new file mode 100644 index 0000000..57d0b73 --- /dev/null +++ b/src/app/features/releases/components/release-notifications/release-notifications.component.ts @@ -0,0 +1,43 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { map } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavTabsComponent } from '../../../../shared/components/nav-tabs'; +import { LoadingComponent } from '../../../../shared/components/loading'; +import { Notification, NotificationType } from '../../../../core/models'; +import { getClass } from '../../../../shared/utils/notification'; +import { ReleaseInstance } from '../../../../model'; +import { AbstractReleaseInstanceComponent } from '../../shared/components/release-instance/release-instance.component'; + +@Component({ + selector: 'app-notifications-log', + standalone: true, + imports: [CommonModule, NavTabsComponent, LoadingComponent], + templateUrl: './release-notifications.component.html', + styleUrls: ['./release-notifications.component.scss'], + animations: [], +}) +export class ReleaseNotificationsComponent extends AbstractReleaseInstanceComponent implements OnInit { + notifications: Notification[] = []; + + constructor() { + super(); + } + + override updateDataSourceForInstance(instance: ReleaseInstance): void { + this.notificationService.messages$ + .pipe( + takeUntilDestroyed(this.destroyRef), + map(notifications => + notifications.filter(n => n.service === instance.metadata.name && n.project === instance.metadata.namespace) + ) + ) + .subscribe(filteredNotifications => { + this.notifications = filteredNotifications; + }); + } + + getClass(type: NotificationType): { msg: string; icon: string } { + return getClass(type); + } +} diff --git a/src/app/features/releases/components/release-summary/release-summary.component.html b/src/app/features/releases/components/release-summary/release-summary.component.html new file mode 100644 index 0000000..7b1bf1a --- /dev/null +++ b/src/app/features/releases/components/release-summary/release-summary.component.html @@ -0,0 +1,313 @@ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Release: + + + + + {{ instance?.metadata?.name }} + + + {{ showReleaseYaml ? 'keyboard_double_arrow_up' : 'code' }} + + + content_copy + + + download + +
+ +
State: + + + {{ instance?.statusIcon }} + + {{ instance?.statusText! | titleCase }} + + + event_note + +
Package: + + folder_zip + {{ instance?.spec?.package?.repository }}:{{ instance?.spec?.package?.tag }} + + + {{ showPackageYaml ? 'keyboard_double_arrow_up' : 'code' }} + + + content_copy + + + download + +
+ + + + + + +
+ +
+
+
Created At: + + {{ instance?.metadata?.creationTimestamp }} + +
+
+ +
+ +
+
+
+ Pods Information ({{ pods.length }}) +
+
+
+
+

+ +

+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Name: + + {{ pod.name }} + +
State: + + + {{ pod.statusIcon }} + + {{ pod.statusText! | titleCase }} + + + format_align_left + +
Health: + + + {{ pod.healthIcon }} + + {{ pod.health! | titleCase }} + +
Created At: + + {{ pod.createdAt }} + +
+
+
+ Containers Information ({{ pod.containers.length }}) +
+ +
+
+

+ +

+
+
+
+ + + + + + + + + + + + + + + + + + + +
Name: + + {{ c.name }} + +
State: + + + {{ getStatus(c.state)[1] }} + + {{ c.state! | titleCase }} + + + format_align_left + +
Image: + + {{ c.image }} + +
Reason: + {{ c.reason }} (c.message) +
+
+
+
+
+
No containers found.
+
+ +
+
+
+
+
+ +
+ + info + No pods to display. + +
+
+
+
diff --git a/src/app/features/releases/components/release-summary/release-summary.component.scss b/src/app/features/releases/components/release-summary/release-summary.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/releases/components/release-summary/release-summary.component.ts b/src/app/features/releases/components/release-summary/release-summary.component.ts new file mode 100644 index 0000000..29bddfd --- /dev/null +++ b/src/app/features/releases/components/release-summary/release-summary.component.ts @@ -0,0 +1,183 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { catchError, EMPTY, firstValueFrom, Observable, shareReplay } from 'rxjs'; +import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip'; +import { EndpointsFromUsagePipe, TitleCasePipe } from '../../../../shared/pipes'; +import { NavTabsComponent } from '../../../../shared/components/nav-tabs'; +import { LoadingComponent } from '../../../../shared/components/loading'; +import { OpenApiV3Schema, PodContainer, PodInstance, toRelease } from '../../../../model'; +import { LogTerminalComponent } from '../../../../shared/components/log-terminal'; +import { YamlEditorComponent } from '../../../../shared/components/yaml-editor'; +import { Release } from '../../../../api/_model'; +import { + copyToClipboardYaml, + downloadYaml, + errorMessage, + extractPackage, + showCopiedTooltip, +} from '../../../../shared/utils'; +import { AbstractReleaseInstanceComponent } from '../../shared/components/release-instance/release-instance.component'; + +@Component({ + selector: 'app-summary-log', + standalone: true, + imports: [ + CommonModule, + MatTooltipModule, + NavTabsComponent, + LoadingComponent, + LogTerminalComponent, + YamlEditorComponent, + TitleCasePipe, + ], + providers: [EndpointsFromUsagePipe], + templateUrl: './release-summary.component.html', + styleUrls: ['./release-summary.component.scss'], + animations: [], +}) +export class ReleaseSummaryComponent extends AbstractReleaseInstanceComponent implements OnInit { + copyTooltip = 'Copy to clipboard'; + showReleaseYaml = false; + showPackageYaml = false; + private _packageVersion$?: Observable; + + constructor() { + super(); + } + + onViewEvents() { + const segments = this.router.url.split('/'); + segments[segments.length - 1] = 'events'; + const targetUrl = segments.join('/'); + this.router.navigateByUrl(targetUrl); + } + + onViewPodLogs(pod: PodInstance) { + if (!pod || !pod.containers || pod.containers.length === 0) { + console.warn('No containers found for pod:', pod?.name); + return; + } + + const firstContainer = pod.containers[0]; + + const segments = this.router.url.split('/'); + segments[segments.length - 1] = 'logs'; + const targetUrl = segments.join('/'); + + this.router.navigate([targetUrl], { + queryParams: { + pod: pod.name, + container: firstContainer.name, + }, + }); + } + + onViewContainerLogs(pod: PodInstance, container: PodContainer) { + const segments = this.router.url.split('/'); + segments[segments.length - 1] = 'logs'; + const targetUrl = segments.join('/'); + + this.router.navigate([targetUrl], { + queryParams: { + pod: pod.name, + container: container.name, + }, + }); + } + + onReleaseViewYaml() { + this.showPackageYaml = false; + this.showReleaseYaml = !this.showReleaseYaml; + } + + async onReleaseCopyToClipboard(tip: MatTooltip): Promise { + if (!this.release) return; + + try { + await copyToClipboardYaml(this.release); + showCopiedTooltip(tip, { + resetText: 'Copy to clipboard', + duration: 1000, + updateBinding: text => (this.copyTooltip = text), + }); + } catch (err) { + console.error('Failed to copy Release YAML:', err); + } + } + + onReleaseDownload(): void { + if (!this.release) return; + downloadYaml(this.release, { + filename: `release-${this.release.metadata?.name ?? 'release'}.yaml`, + }); + } + + onPackageViewYaml() { + this.showReleaseYaml = false; + this.showPackageYaml = !this.showPackageYaml; + } + + async onPackageCopyToClipboard(tip: MatTooltip): Promise { + if (!this._packageVersion$) return; + + try { + const pkgJson = await this._packageVersion$.pipe().toPromise(); + if (!pkgJson) return; + + await copyToClipboardYaml(pkgJson); + showCopiedTooltip(tip, { + resetText: 'Copy to clipboard', + duration: 1000, + updateBinding: text => (this.copyTooltip = text), + }); + } catch (err) { + console.error('Failed to copy Package YAML:', err); + } + } + + onPackageDownload(): void { + if (!this._packageVersion$) return; + const repo = this.instance?.spec?.package?.repository; + const tag = this.instance?.spec?.package?.tag; + + if (!repo || !tag) { + console.warn('Package download skipped: missing repository or tag.', { repo, tag }); + return; + } + + firstValueFrom(this._packageVersion$) + .then(pkgJson => { + if (!pkgJson) return; + const pkg = extractPackage(repo); + downloadYaml(pkgJson, { filename: `package-${pkg}-${tag}.yaml` }); + }) + .catch(err => { + console.error('Failed to download Package YAML:', err); + }); + } + + get release(): Release | undefined { + return toRelease(this.instance); + } + + get package$(): Observable | undefined { + const catalogId = this.currentCatalog?.id; + const repo = this.instance?.spec?.package?.repository; + const tag = this.instance?.spec?.package?.tag; + const pkg = repo ? extractPackage(repo) : undefined; + + if (!catalogId || !pkg || !tag) return undefined; + + if (!this._packageVersion$) { + this._packageVersion$ = this.catalogService.getPackageVersion(catalogId, pkg, tag).pipe( + catchError(err => { + this.notificationService.onError(pkg, '', '', errorMessage(err)); + return EMPTY; + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + } + + return this._packageVersion$; + } +} diff --git a/src/app/features/releases/index.ts b/src/app/features/releases/index.ts index cc33622..728f403 100644 --- a/src/app/features/releases/index.ts +++ b/src/app/features/releases/index.ts @@ -1,5 +1,11 @@ -export * from './components/releases-list-card/release-list-card.component'; -export * from './components/releases-list-table/release-list-table.component'; +export * from './components/release-list-card/release-list-card.component'; +export * from './components/release-list-table/release-list-table.component'; export * from './components/release-create-update/release-create-update.component'; export * from './components/package-select/package-select.component'; +export * from './components/release-log/release-log.component'; +export * from './components/release-notifications/release-notifications.component'; +export * from './components/release-events/release-events.component'; export * from './services/git-release.service'; +export * from './services/k8s-release.service'; +export * from './services/pod-info.service'; +export * from './services/release-events.service'; diff --git a/src/app/features/releases/services/pod-info.service.ts b/src/app/features/releases/services/pod-info.service.ts new file mode 100644 index 0000000..fd7a4b7 --- /dev/null +++ b/src/app/features/releases/services/pod-info.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { PodInfo } from '../../../api/_model'; + +@Injectable({ + providedIn: 'root', +}) +export class PodInfoService { + constructor(private readonly http: HttpClient) {} + + list(clusterId: string, namespace: string, releaseName: string): Observable { + return this.http.get(`/clusters/${clusterId}/namespaces/${namespace}/releases/${releaseName}/pods`); + } +} diff --git a/src/app/features/releases/services/release-events.service.ts b/src/app/features/releases/services/release-events.service.ts new file mode 100644 index 0000000..77043c4 --- /dev/null +++ b/src/app/features/releases/services/release-events.service.ts @@ -0,0 +1,15 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { K8sEvent } from '../../../model'; + +@Injectable({ + providedIn: 'root', +}) +export class ReleaseEventsService { + constructor(private readonly http: HttpClient) {} + + list(clusterId: string, namespace: string, releaseName: string): Observable { + return this.http.get(`/clusters/${clusterId}/namespaces/${namespace}/releases/${releaseName}/events`); + } +} diff --git a/src/app/features/releases/shared/components/release-base/release-base.component.ts b/src/app/features/releases/shared/components/release-base/release-base.component.ts index 2698c7a..f5c0858 100644 --- a/src/app/features/releases/shared/components/release-base/release-base.component.ts +++ b/src/app/features/releases/shared/components/release-base/release-base.component.ts @@ -1,4 +1,4 @@ -import { DestroyRef, inject, Injectable } from '@angular/core'; +import { DestroyRef, Directive, inject, OnInit } from '@angular/core'; import { select, Store } from '@ngrx/store'; import { ActivatedRoute, Router } from '@angular/router'; import { catchError, combineLatest, EMPTY, filter, interval, map, of, switchMap, take, takeWhile, tap } from 'rxjs'; @@ -15,13 +15,13 @@ import { SearchFilterService } from '../../../../../shared/components/search-fil import { TitleBarService } from '../../../../../shared/components/content-header-title'; import { SidebarService } from '../../../../../core/layout/sidebar'; import { EndpointsFromUsagePipe } from '../../../../../shared/pipes'; -import { errorMessage } from '../../../../../core/models'; import { AppConfigService } from '../../../../../core/config'; import { GitReleaseService } from '../../../services/git-release.service'; import { K8sReleaseService } from '../../../services/k8s-release.service'; +import { errorMessage } from '../../../../../shared/utils'; -@Injectable() -export abstract class AbstractReleaseBaseComponent { +@Directive() +export abstract class AbstractReleaseBaseComponent implements OnInit { protected readonly kubocdReleases = inject(KuboCDReleases); protected readonly catalogService = inject(CatalogService); protected readonly gitReleaseService = inject(GitReleaseService); @@ -35,7 +35,7 @@ export abstract class AbstractReleaseBaseComponent { protected readonly store = inject>(Store); protected readonly destroyRef = inject(DestroyRef); protected readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); + protected readonly router = inject(Router); protected isLoaded = false; @@ -58,15 +58,23 @@ export abstract class AbstractReleaseBaseComponent { abstract updateDataSource(instances: ReleaseInstance[]): void; - onInit(): void { + ngOnInit(): void { this.isLoaded = false; this.submissionMode = this.appConfigService.getSubmissionMode(); combineLatest([ this.store.pipe(select(getClusterId)), this.store.pipe(select(getProjectName)), - this.route.parent!.paramMap.pipe( - map(params => params.get('service') || '-'), + (this.route.parent + ? this.route.parent.paramMap.pipe( + map(params => params.get('service')), + switchMap(service => + service ? of(service) : this.route.paramMap.pipe(map(params => params.get('service'))) + ) + ) + : this.route.paramMap.pipe(map(params => params.get('service'))) + ).pipe( + map(service => service || '-'), switchMap(catalogId => this.catalogService.listById(catalogId)) ), ]) @@ -81,7 +89,6 @@ export abstract class AbstractReleaseBaseComponent { this.titleBarService.setCurrentMenu(catalog.id); this.sidebarService.setActiveMenu(catalog.id); this.isLoaded = false; - return this.kubocdReleases.loadKuboCDReleases(clusterId, [projectName]); }), tap(releases => { @@ -92,7 +99,7 @@ export abstract class AbstractReleaseBaseComponent { this.isLoaded = true; }), catchError(err => { - this.notificationService.onError('Namespaces', `Failed to load namespaces: ${err.message || err}`); + this.notificationService.onError('Namespaces', '', '', `Failed to load namespaces: ${err.message || err}`); this.isLoaded = true; return EMPTY; }) @@ -104,7 +111,7 @@ export abstract class AbstractReleaseBaseComponent { this.searchChanged(search); }, error: error => { - this.notificationService.onError('search', `Search error, ${errorMessage(error)}`); + this.notificationService.onError('search', '', '', `Search error, ${errorMessage(error)}`); }, }); } @@ -142,6 +149,10 @@ export abstract class AbstractReleaseBaseComponent { this.updateDataSource(this.filtredInstances); } + protected showDetails(releaseName: string) { + this.router.navigate([`services/${this.currentCatalog.id}/instances/${releaseName}/summary`]); + } + protected onDeleteRelease(name: string) { this.selectedRelease = name; this.showDialog = true; @@ -186,13 +197,17 @@ export abstract class AbstractReleaseBaseComponent { this.updateDataSource(this.instances); this.searchChanged(this.search); this.notificationService.onSuccess( - `${name}/${this.currentProjectName}`, + name, + this.currentProjectName, + this.currentCatalog.id, `was successfully deleted from ${this.submissionMode === 'git' ? 'Git' : 'Kubernetes'}.` ); }), catchError(error => { this.notificationService.onError( - `${name}/${this.currentProjectName}`, + name, + this.currentProjectName, + this.currentCatalog.id, `Polling failed: ${errorMessage(error)}` ); return of(null); @@ -204,7 +219,9 @@ export abstract class AbstractReleaseBaseComponent { .subscribe({ error: error => { this.notificationService.onError( - `${name}/${this.currentProjectName}`, + name, + this.currentProjectName, + this.currentCatalog.id, `Delete failed: ${errorMessage(error)}` ); }, diff --git a/src/app/features/releases/shared/components/release-details/release-details.component.html b/src/app/features/releases/shared/components/release-details/release-details.component.html new file mode 100644 index 0000000..f2e7361 --- /dev/null +++ b/src/app/features/releases/shared/components/release-details/release-details.component.html @@ -0,0 +1,78 @@ + + +
+ +
+
+
+ + + +
+
+ +
+ +
diff --git a/src/app/features/releases/shared/components/release-details/release-details.component.scss b/src/app/features/releases/shared/components/release-details/release-details.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/releases/shared/components/release-details/release-details.component.ts b/src/app/features/releases/shared/components/release-details/release-details.component.ts new file mode 100644 index 0000000..359225a --- /dev/null +++ b/src/app/features/releases/shared/components/release-details/release-details.component.ts @@ -0,0 +1,59 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, RouterOutlet } from '@angular/router'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { EndpointsFromUsagePipe, TitleCasePipe } from '../../../../../shared/pipes'; +import { NavTabsComponent } from '../../../../../shared/components/nav-tabs'; +import { LoadingComponent } from '../../../../../shared/components/loading'; +import { ReleaseInstance } from '../../../../../model'; +import { AbstractReleaseInstanceComponent } from '../release-instance/release-instance.component'; +import { extractPackage } from '../../../../../shared/utils'; +import { DialogComponent } from '../../../../../shared/components/dialog'; + +@Component({ + selector: 'app-release-details', + standalone: true, + imports: [ + CommonModule, + RouterOutlet, + RouterLink, + MatTooltipModule, + NavTabsComponent, + LoadingComponent, + DialogComponent, + TitleCasePipe, + ], + providers: [EndpointsFromUsagePipe], + templateUrl: './release-details.component.html', + styleUrls: ['./release-details.component.scss'], + animations: [], +}) +export class ReleaseDetailsComponent extends AbstractReleaseInstanceComponent implements OnInit { + constructor() { + super(); + } + + onDelete(instance?: ReleaseInstance) { + const name = instance?.metadata?.name; + if (!name) { + console.warn('Delete skipped: missing instance name.', instance); + return; + } + super.onDeleteRelease(name); + } + + onEdit(instance?: ReleaseInstance) { + const repo = instance?.spec?.package?.repository; + const name = instance?.metadata?.name; + + if (!repo || !name) { + console.warn('Edit skipped: missing repository or name.', { repo, name, instance }); + return; + } + + const pkg = extractPackage(repo); + super.edit(pkg, name); + } + + onFavorite(instance?: ReleaseInstance) {} +} diff --git a/src/app/features/releases/shared/components/release-instance/release-instance.component.ts b/src/app/features/releases/shared/components/release-instance/release-instance.component.ts new file mode 100644 index 0000000..82860bd --- /dev/null +++ b/src/app/features/releases/shared/components/release-instance/release-instance.component.ts @@ -0,0 +1,77 @@ +import { inject, Injectable, OnInit } from '@angular/core'; +import { AbstractReleaseBaseComponent } from '../../../shared'; +import { PodInstance, ReleaseInstance, ToStatusView } from '../../../../../model'; +import { PodInfoService } from '../../../services/pod-info.service'; +import { errorMessage } from '../../../../../shared/utils'; + +@Injectable() +export abstract class AbstractReleaseInstanceComponent extends AbstractReleaseBaseComponent implements OnInit { + protected readonly podInfoService = inject(PodInfoService); + + tabs: string[] = ['Summary', 'Notifications', 'Events', 'Logs']; + instance?: ReleaseInstance; + + pods: PodInstance[] = []; + podsLoaded = false; + + constructor() { + super(); + } + + override updateDataSource(instances: ReleaseInstance[]): void { + const release = + this.route.snapshot.paramMap.get('release') ?? this.route.parent?.snapshot.paramMap.get('release') ?? ''; + + this.instance = instances.find( + r => r.metadata.name === release && r.metadata.namespace === this.currentProjectName + ); + + if (this.instance) { + this.updateDataSourceForInstance(this.instance); + } + } + + getStatus(status: string): [string, string] { + return ToStatusView(status); + } + + updateDataSourceForInstance(instance: ReleaseInstance): void { + const name = instance?.metadata?.name; + + if (!name) { + console.warn('Skipping pod info fetch: instance name is missing.', instance); + this.pods = []; + this.podsLoaded = true; + return; + } + + this.podInfoService.list(this.currentClusterId, this.currentProjectName, name).subscribe({ + next: pods => { + this.pods = pods.map(pod => { + const [statusLabel, statusIcon] = ToStatusView(pod.state); + const [healthLabel, healthIcon] = ToStatusView(pod.health); + return { + ...pod, + statusLabel, + statusIcon, + statusText: pod.state, + healthLabel, + healthIcon, + healthText: pod.health, + }; + }); + this.podsLoaded = true; + }, + error: err => { + this.notificationService.onError( + name, + this.currentProjectName, + this.currentCatalog.id, + `Failed to fetch pods: ${errorMessage(err)}` + ); + this.pods = []; + this.podsLoaded = true; + }, + }); + } +} diff --git a/src/app/features/releases/shared/index.ts b/src/app/features/releases/shared/index.ts index 00d36a0..c0356d2 100644 --- a/src/app/features/releases/shared/index.ts +++ b/src/app/features/releases/shared/index.ts @@ -1,2 +1,3 @@ export * from './components/release-base/release-base.component'; export * from './components/release-list/release-list.component'; +export * from './components/release-details/release-details.component'; diff --git a/src/app/model/index.ts b/src/app/model/index.ts index 6c4da05..29183e5 100644 --- a/src/app/model/index.ts +++ b/src/app/model/index.ts @@ -1,6 +1,8 @@ export * from '../core/store/auth.state'; export * from './status.model'; export * from './release.instance'; -export * from './package'; -export * from './project'; +export * from './package.model'; +export * from './project.model'; export * from './catalog.model'; +export * from './pod.model'; +export * from './k8s-event.model'; diff --git a/src/app/model/k8s-event.model.ts b/src/app/model/k8s-event.model.ts new file mode 100644 index 0000000..a33c7cf --- /dev/null +++ b/src/app/model/k8s-event.model.ts @@ -0,0 +1,14 @@ +export interface K8sEvent { + metadata: { + name: string; + namespace: string; + }; + + message: string; + reason: string; + type: string; + firstTimestamp?: string; + lastTimestamp?: string; + count?: number; + eventTime?: string | null; +} diff --git a/src/app/model/package.ts b/src/app/model/package.model.ts similarity index 100% rename from src/app/model/package.ts rename to src/app/model/package.model.ts diff --git a/src/app/model/pod.model.ts b/src/app/model/pod.model.ts new file mode 100644 index 0000000..e72b717 --- /dev/null +++ b/src/app/model/pod.model.ts @@ -0,0 +1,12 @@ +import { PodInfo } from '../api/_model'; + +export interface PodInstance extends PodInfo { + statusIcon: string; + statusLabel: string; + statusText: string; + healthIcon: string; + healthLabel: string; + healthText: string; +} + +export type PodContainer = PodInfo['containers'][number]; diff --git a/src/app/model/project.ts b/src/app/model/project.model.ts similarity index 100% rename from src/app/model/project.ts rename to src/app/model/project.model.ts diff --git a/src/app/model/release.instance.ts b/src/app/model/release.instance.ts index 8ec338f..407cfcb 100644 --- a/src/app/model/release.instance.ts +++ b/src/app/model/release.instance.ts @@ -1,4 +1,5 @@ import { Release } from '../api/_model'; +import { pickFields } from '../shared/utils'; export interface ReleaseInstance extends Release { icon: string; @@ -8,3 +9,10 @@ export interface ReleaseInstance extends Release { statusIcon: string; statusLabel: string; } + +export const ReleaseKeys = ['apiVersion', 'kind', 'metadata', 'spec', 'status']; + +export function toRelease(instance?: ReleaseInstance): Release | undefined { + if (!instance) return undefined; + return pickFields(instance, ReleaseKeys) as Release; +} diff --git a/src/app/model/status.model.ts b/src/app/model/status.model.ts index 42e7303..47fb1d7 100644 --- a/src/app/model/status.model.ts +++ b/src/app/model/status.model.ts @@ -17,16 +17,35 @@ export const RELEASE_PHASE_WAIT_HELM_RELEASES = 'WAIT_HREL'; export const RELEASE_PHASE_WAIT_DEPENDENCIES = 'WAIT_DEPS'; export const RELEASE_PHASE_SUSPENDED = 'SUSPENDED'; +export const STATE_SUCCEEDED = 'SUCCEEDED'; +export const STATE_RUNNING = 'RUNNING'; +export const STATE_WAITING = 'WAITING'; +export const STATE_TERMINATED = 'TERMINATED'; +export const STATE_UNKNOWN = 'UNKNOWN'; + +export const STATE_HEALTHY = 'HEALTHY'; +export const STATE_COMPLETED = 'COMPLETED'; +export const STATE_NOT_READY = 'NOTREADY'; +export const STATE_PENDING = 'PENDING'; +export const STATE_FAILED = 'FAILED'; + export function ToStatusView(status: string | undefined): [string, string] { const statusLabel = status?.toLowerCase() || 'unknown'; switch (status?.toUpperCase()) { + case STATE_RUNNING: + case STATE_HEALTHY: + case STATE_COMPLETED: + case STATE_SUCCEEDED: case STATUS_ACTIVE: case RELEASE_PHASE_READY: return [statusLabel, 'check_circle']; + case STATE_FAILED: case STATUS_FAILED: case RELEASE_PHASE_ERROR: return [statusLabel, 'error']; + case STATE_PENDING: + case STATE_WAITING: case RELEASE_PHASE_WAIT_OCI: case RELEASE_PHASE_WAIT_HELM_REPO: case RELEASE_PHASE_WAIT_HELM_RELEASES: @@ -36,6 +55,7 @@ export function ToStatusView(status: string | undefined): [string, string] { case STATUS_RUNNING: case RELEASE_PHASE_SUSPENDED: return [statusLabel, 'pause_circle_filled']; + case STATE_TERMINATED: case STATUS_STOPPED: return [statusLabel, 'stop_circle']; default: diff --git a/src/app/shared/components/log-terminal/components/log-terminal.component.html b/src/app/shared/components/log-terminal/components/log-terminal.component.html new file mode 100644 index 0000000..af45ee9 --- /dev/null +++ b/src/app/shared/components/log-terminal/components/log-terminal.component.html @@ -0,0 +1,47 @@ +
+
+
+ +
+
+ +
+
+
+
diff --git a/src/app/shared/components/log-terminal/components/log-terminal.component.scss b/src/app/shared/components/log-terminal/components/log-terminal.component.scss new file mode 100644 index 0000000..b88c4fe --- /dev/null +++ b/src/app/shared/components/log-terminal/components/log-terminal.component.scss @@ -0,0 +1,27 @@ +.terminal-container { + border: 1px solid var(--border-color-translucent); +} + +.terminal-toolbar { + border-top: 1px solid var(--border-color-translucent); + border-left: 1px solid var(--border-color-translucent); + border-bottom: none; + border-right: 1px solid var(--border-color-translucent); + border-top-left-radius: 5px; + margin-bottom: -5px; + background-color: var(--card-bg-color); +} + +.xterm-rows { + color: var(--text-primary-color) !important; + background-color: var(--card-bg-color) !important; +} +.xterm .xterm-viewport { + background-color: var(--card-bg-color) !important; +} +.xterm .xterm-screen { + padding: 0 !important; +} +.xterm .xterm-cursor { + display: none !important; +} diff --git a/src/app/shared/components/log-terminal/components/log-terminal.component.ts b/src/app/shared/components/log-terminal/components/log-terminal.component.ts new file mode 100644 index 0000000..139d9e3 --- /dev/null +++ b/src/app/shared/components/log-terminal/components/log-terminal.component.ts @@ -0,0 +1,145 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { CommonModule } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { LogStreamingService } from '../services/log-streaming.service'; +import { formatAndColorize, RESET, COLORS, errorMessage } from '../../../utils'; + +@Component({ + selector: 'app-log-terminal', + standalone: true, + imports: [CommonModule, MatTooltipModule], + encapsulation: ViewEncapsulation.None, + templateUrl: './log-terminal.component.html', + styleUrls: ['./log-terminal.component.scss'], +}) +export class LogTerminalComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('terminalContainer', { static: true }) terminalContainer!: ElementRef; + @Input() logUrl = ''; + @Input() pod = ''; + @Input() container = ''; + + terminal!: Terminal; + fitAddon!: FitAddon; + + paused = false; + autoScroll = true; + + private logBuffer: string[] = []; + private logSubscription?: Subscription; + private resizeObserver!: ResizeObserver; + + constructor(private logStreamingService: LogStreamingService) {} + + ngOnInit(): void { + this.initTerminal(); + this.subscribeToLogs(); + } + + private initTerminal(): void { + this.terminal = new Terminal({ + cursorBlink: false, + fontSize: 14, + fontWeight: 'normal', + lineHeight: 1.4, + convertEol: true, + theme: { + background: getComputedStyle(document.documentElement).getPropertyValue('--card-bg-color') || '#1e1e1e', + foreground: getComputedStyle(document.documentElement).getPropertyValue('--text-primary-color') || '#d4d4d4', + selectionBackground: + getComputedStyle(document.documentElement).getPropertyValue('--theme-text-secondary') || '#B2DFDB', + }, + }); + this.fitAddon = new FitAddon(); + this.terminal.loadAddon(this.fitAddon); + this.terminal.open(this.terminalContainer.nativeElement); + } + + private subscribeToLogs(): void { + this.logSubscription?.unsubscribe(); + this.logBuffer = []; + this.terminal.clear(); + + this.logSubscription = this.logStreamingService.streamLogs(this.logUrl).subscribe({ + next: (line: string) => { + const colored = formatAndColorize(line); + + if (this.paused) { + this.logBuffer.push(colored); + } else { + this.terminal.write(' ' + colored + '\r\n'); + if (this.autoScroll) { + this.terminal.scrollToBottom(); + } + } + }, + error: (err: any) => { + const msg = errorMessage(err) || 'Connection lost. Trying to reconnect...'; + const line = `${COLORS['ERROR']}${msg}${RESET}`; + this.terminal.writeln(line + '\r\n'); + }, + }); + } + + togglePause(): void { + this.paused = !this.paused; + if (!this.paused && this.logBuffer.length > 0) { + this.terminal.write(this.logBuffer.join('\r\n') + '\r\n'); + if (this.autoScroll) { + this.terminal.scrollToBottom(); + } + this.logBuffer = []; + } + } + + toggleAutoScroll(): void { + this.autoScroll = !this.autoScroll; + } + + downloadLogs(): void { + if (!this.logUrl) return; + + this.logStreamingService.downloadLogs(this.logUrl).subscribe({ + next: blob => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `logs-${this.pod}-${this.container}.log`; + a.click(); + URL.revokeObjectURL(url); + }, + error: err => { + console.error('Failed to download logs:', err); + }, + }); + } + + ngAfterViewInit() { + this.resizeObserver = new ResizeObserver(() => { + this.fitTerminalToContainer(); + }); + this.resizeObserver.observe(this.terminalContainer.nativeElement); + this.fitTerminalToContainer(); + } + + ngOnDestroy(): void { + this.resizeObserver.disconnect(); + this.logSubscription?.unsubscribe(); + this.terminal.dispose(); + } + + private fitTerminalToContainer() { + this.fitAddon.fit(); + } +} diff --git a/src/app/shared/components/log-terminal/index.ts b/src/app/shared/components/log-terminal/index.ts new file mode 100644 index 0000000..889d36e --- /dev/null +++ b/src/app/shared/components/log-terminal/index.ts @@ -0,0 +1,2 @@ +export * from './components/log-terminal.component'; +export * from './services/log-streaming.service'; diff --git a/src/app/shared/components/log-terminal/services/log-streaming.service.ts b/src/app/shared/components/log-terminal/services/log-streaming.service.ts new file mode 100644 index 0000000..0765b82 --- /dev/null +++ b/src/app/shared/components/log-terminal/services/log-streaming.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { SseClient } from 'ngx-sse-client'; +import { AuthService } from '../../../../core/auth'; +import { AppConfigService } from '../../../../core/config'; + +@Injectable({ + providedIn: 'root', +}) +export class LogStreamingService { + constructor( + private authService: AuthService, + private config: AppConfigService, + private sseClient: SseClient, + private http: HttpClient + ) {} + + streamLogs(logUrl: string): Observable { + const token = this.authService.getAccessToken(); + const apiUrl = `${this.config.getConfig().okdpApi.apiUrl}${logUrl}`; + const headers = new HttpHeaders().set('Authorization', `Bearer ${token}`).set('Accept', 'text/event-stream'); + + const stream$ = this.sseClient + .stream(apiUrl, { keepAlive: true, reconnectionDelay: 3000, responseType: 'event' }, { headers }) + .pipe( + switchMap(event => { + if (event.type === 'error') { + return of(); + } + return of((event as MessageEvent).data); + }) + ); + + return stream$; + } + + /** + * Downloads logs directly from the backend with authentication + */ + downloadLogs(downloadUrl: string): Observable { + const token = this.authService.getAccessToken(); + const apiUrl = new URL(`${this.config.getConfig().okdpApi.apiUrl}${downloadUrl}`); + + apiUrl.searchParams.set('download', 'true'); + + const headers = new HttpHeaders().set('Authorization', `Bearer ${token}`); + + return this.http.get(apiUrl.toString(), { + headers, + responseType: 'blob', + }); + } +} diff --git a/src/app/shared/components/nav-tabs/components/nav-tabs.component.html b/src/app/shared/components/nav-tabs/components/nav-tabs.component.html index 6ef5a15..f0cc953 100644 --- a/src/app/shared/components/nav-tabs/components/nav-tabs.component.html +++ b/src/app/shared/components/nav-tabs/components/nav-tabs.component.html @@ -1,12 +1,15 @@ - + diff --git a/src/app/shared/components/nav-tabs/components/nav-tabs.component.scss b/src/app/shared/components/nav-tabs/components/nav-tabs.component.scss index e69de29..c5667a1 100644 --- a/src/app/shared/components/nav-tabs/components/nav-tabs.component.scss +++ b/src/app/shared/components/nav-tabs/components/nav-tabs.component.scss @@ -0,0 +1,9 @@ +.tab-indicator { + position: absolute; + bottom: 0; + height: 1px; + background: var(--text-okdp); + border-radius: 1px 1px 0 0; + transition: left 0.3s cubic-bezier(.4,0,.2,1), width 0.3s cubic-bezier(.4,0,.2,1); + z-index: 10; +} \ No newline at end of file diff --git a/src/app/shared/components/nav-tabs/components/nav-tabs.component.ts b/src/app/shared/components/nav-tabs/components/nav-tabs.component.ts index e392986..bf6ea9f 100644 --- a/src/app/shared/components/nav-tabs/components/nav-tabs.component.ts +++ b/src/app/shared/components/nav-tabs/components/nav-tabs.component.ts @@ -1,6 +1,19 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + EventEmitter, + Input, + Output, + QueryList, + ViewChild, + ViewChildren, +} from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterLink, RouterLinkActive } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'; +import { filter } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { TrimWhiteSpacePipe } from '../../../pipes'; @Component({ @@ -12,17 +25,92 @@ import { TrimWhiteSpacePipe } from '../../../pipes'; styleUrls: ['./nav-tabs.component.scss'], animations: [], }) -export class NavTabsComponent { +export class NavTabsComponent implements AfterViewInit { @Input() basePath: string = ''; @Input() items: string[] = []; @Input() display: string = ''; @Output() tabChange = new EventEmitter(); - constructor(private trimWhiteSpacePipe: TrimWhiteSpacePipe) {} + @ViewChildren('tab') tabElements!: QueryList; + @ViewChild('tabList') tabList!: ElementRef; + + selectedTabIndex = 0; + indicatorWidth = 0; + indicatorLeft = 0; + + constructor( + private trimWhiteSpacePipe: TrimWhiteSpacePipe, + private router: Router, + private route: ActivatedRoute, + private destroyRef: DestroyRef + ) {} onTabChange(item: string): void { item = this.trimWhiteSpacePipe.transform(item); this.tabChange.emit(item); } + + getRouterLink(item: string): any[] { + const segment = item.replace(/\s/g, '').toLowerCase(); + return this.basePath ? [this.basePath, segment] : [segment]; + } + + ngAfterViewInit() { + this.setSelectedTabFromUrl(); + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.setSelectedTabFromUrl(); + }); + + window.addEventListener('resize', () => this.updateIndicator()); + } + + activateTab(idx: number, item: string) { + this.selectedTabIndex = idx; + this.onTabChange(item); + this.updateIndicator(); + this.navigate(item); + } + + updateIndicator() { + setTimeout(() => { + const tabs = this.tabElements.toArray(); + const activeTab = tabs[this.selectedTabIndex]?.nativeElement; + if (activeTab) { + this.indicatorWidth = activeTab.offsetWidth; + this.indicatorLeft = activeTab.offsetLeft; + } + }); + } + + navigate(item: string) { + const segment = item.replace(/\s/g, '').toLowerCase(); + if (!this.basePath) { + this.router.navigate([segment], { + relativeTo: this.route, + queryParams: {}, + queryParamsHandling: '', + }); + } + } + + setSelectedTabFromUrl() { + const pathOnly = this.router.url.split('?')[0]; + const segments = pathOnly.split('/').filter(Boolean); + const currentTabSegment = segments[segments.length - 1] ?? ''; + + const tabIndex = this.items.findIndex( + item => item.replace(/\s/g, '').toLowerCase() === currentTabSegment.toLowerCase() + ); + + if (tabIndex > -1) { + this.selectedTabIndex = tabIndex; + this.updateIndicator(); + } + } } diff --git a/src/app/shared/components/yaml-editor/components/yaml-editor.component.html b/src/app/shared/components/yaml-editor/components/yaml-editor.component.html new file mode 100644 index 0000000..9714a32 --- /dev/null +++ b/src/app/shared/components/yaml-editor/components/yaml-editor.component.html @@ -0,0 +1,11 @@ +
+
+
+
+
+
{{ yamlText }}
+
+
+
+
+
diff --git a/src/app/shared/components/yaml-editor/components/yaml-editor.component.scss b/src/app/shared/components/yaml-editor/components/yaml-editor.component.scss new file mode 100644 index 0000000..5a0fb61 --- /dev/null +++ b/src/app/shared/components/yaml-editor/components/yaml-editor.component.scss @@ -0,0 +1,53 @@ +:host { + display: block; + max-width: 100%; + width: 100%; + box-sizing: border-box; +} + +.yaml-editor-container { + width: 100%; + max-width: 100%; + box-sizing: border-box; + margin-left: 0 !important; + margin-right: 0 !important; + overflow: visible; + transform-origin: top; + animation: revealDown 0.3s cubic-bezier(.4,0,.2,1) both; +} + +@keyframes revealDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card-text { + max-width: 100%; + overflow: visible; +} + +.yaml-editor-container pre, +.yaml-editor-container code { + background: transparent; + border: 0; + margin: 0; + padding: 0; + + display: block; + max-width: 100%; + white-space: pre-wrap !important; + overflow-wrap: anywhere; + word-break: break-word; + overflow-x: hidden; + overflow-y: visible; +} + +code { + font-size: 1rem; +} diff --git a/src/app/shared/components/yaml-editor/components/yaml-editor.component.ts b/src/app/shared/components/yaml-editor/components/yaml-editor.component.ts new file mode 100644 index 0000000..96ee49a --- /dev/null +++ b/src/app/shared/components/yaml-editor/components/yaml-editor.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnChanges, SimpleChanges, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; +import { stringify } from 'yaml'; +import 'prismjs'; +import 'prismjs/components/prism-yaml'; +import { CommonModule } from '@angular/common'; + +declare let Prism: any; + +@Component({ + selector: 'app-yaml-editor', + imports: [CommonModule], + templateUrl: './yaml-editor.component.html', + styleUrls: ['./yaml-editor.component.scss'], + standalone: true, +}) +export class YamlEditorComponent implements AfterViewInit, OnChanges { + @Input() jsonObj: any = {}; + + @ViewChild('codeBlock', { static: false }) codeBlock!: ElementRef; + + yamlText: string = ''; + + ngAfterViewInit() { + this.highlight(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.yamlText = stringify(this.jsonObj ?? {}); + this.highlight(); + } + + highlight() { + setTimeout(() => { + if (this.codeBlock) { + Prism.highlightElement(this.codeBlock.nativeElement); + } + }, 0); + } +} diff --git a/src/app/shared/components/yaml-editor/index.ts b/src/app/shared/components/yaml-editor/index.ts new file mode 100644 index 0000000..393054d --- /dev/null +++ b/src/app/shared/components/yaml-editor/index.ts @@ -0,0 +1 @@ +export * from './components/yaml-editor.component'; diff --git a/src/app/shared/pipes/index.ts b/src/app/shared/pipes/index.ts index 93942a0..c5b8004 100644 --- a/src/app/shared/pipes/index.ts +++ b/src/app/shared/pipes/index.ts @@ -1,3 +1,4 @@ export * from './trime-whitespaces.pipe'; export * from './title-case.pipe'; export * from './endpoints-from-usage.pipe'; +export * from './time-ago.pipe'; diff --git a/src/app/shared/pipes/time-ago.pipe.ts b/src/app/shared/pipes/time-ago.pipe.ts new file mode 100644 index 0000000..3ab579f --- /dev/null +++ b/src/app/shared/pipes/time-ago.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; +import { parseISO } from 'date-fns/parseISO'; + +@Pipe({ name: 'timeAgo', standalone: true }) +export class TimeAgoPipe implements PipeTransform { + transform(value: string | Date): string { + const d = typeof value === 'string' ? parseISO(value) : value; + return formatDistanceToNow(d, { addSuffix: true }); + } +} diff --git a/src/app/shared/utils/error.ts b/src/app/shared/utils/error.ts new file mode 100644 index 0000000..84b881d --- /dev/null +++ b/src/app/shared/utils/error.ts @@ -0,0 +1,61 @@ +/** + * Extracts a human-readable error message from various error object shapes. + * + * This function is designed for Angular's HttpErrorResponse and similar error payloads. + * It gracefully handles: + * - `HttpErrorResponse` objects where `error` is a JSON string + * - Direct error objects with a `message` property + * - Plain string errors + * - Fallback to statusText or a generic "Unknown error" + * + * Example input shapes: + * + * 1. Angular HttpErrorResponse with stringified JSON: + * { + * status: 422, + * message: "Http failure response for ...: 422 Unprocessable Entity", + * error: "{\"message\":\"Failed to fetch logs...\",\"status\":422,\"type\":\"k8s_cluster\"}" + * } + * + * 2. Angular HttpErrorResponse with object in `error`: + * { + * status: 404, + * error: { message: "Not found", status: 404 } + * } + * + * 3. Plain Error object: + * { message: "Something went wrong" } + * + * 4. Plain string: + * "Network unreachable" + * + * @param err - The error object (can be any shape from HttpErrorResponse to plain string) + * @returns A string containing the extracted error message + */ +export function errorMessage(err: any): string { + if (!err) return 'Unknown error'; + + let payload: any = err.error ?? err; + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + return payload; + } + } + + if (payload && typeof payload.message === 'string') { + return payload.message; + } + + if (typeof err.message === 'string') { + return err.message; + } + + if (typeof err.statusText === 'string') { + return err.statusText; + } + + return 'Unknown error'; +} diff --git a/src/app/shared/utils/index.ts b/src/app/shared/utils/index.ts index b2ef838..7aa7694 100644 --- a/src/app/shared/utils/index.ts +++ b/src/app/shared/utils/index.ts @@ -1,3 +1,7 @@ export * from './utils'; export * from './json.schema'; +export * from './notification'; export * from './release'; +export * from './tooltip'; +export * from './logging'; +export * from './error'; diff --git a/src/app/shared/utils/logging.ts b/src/app/shared/utils/logging.ts new file mode 100644 index 0000000..3908e04 --- /dev/null +++ b/src/app/shared/utils/logging.ts @@ -0,0 +1,59 @@ +// ===== ANSI helpers ===== +export const ESC = '\x1b['; +export const RESET = `${ESC}0m`; +export const COLORS: Record = { + ERROR: `${ESC}31m`, // dark red + WARN: `${ESC}33m`, // dark yellow (amber) + INFO: '', // plain, uses theme.foreground + DEBUG: `${ESC}35m`, // dark magenta/purple + TRACE: `${ESC}90m`, // gray + DEFAULT: '', // plain, uses theme.foreground +}; + +export function toLevel(raw?: string): 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE' | 'DEFAULT' { + const s = (raw || '').toUpperCase(); + if (s.includes('ERROR')) return 'ERROR'; + if (s.includes('WARN')) return 'WARN'; + if (s.includes('INFO')) return 'INFO'; + if (s.includes('DEBUG')) return 'DEBUG'; + if (s.includes('TRACE')) return 'TRACE'; + return 'DEFAULT'; +} + +/** + * Strip ANSI escape codes. Useful when exporting logs to a .txt file. + */ +export function stripAnsi(input: string): string { + return input.replace(/\u001B\[[0-9;]*m/g, ''); +} + +/** + * Try to parse a log line as JSON and format it. + * If not JSON, returns the original text with inferred level. + */ +export function formatAndColorize(line: string): string { + if (/\u001B\[[0-9;]*m/.test(line)) return line; + + try { + const log = JSON.parse(line); + const timestamp = log.ts ?? log.time ?? ''; + const level = toLevel(log.level); + const logger = log.logger ? `[${log.logger}]` : ''; + const msg = log.msg ?? log.message ?? line; + + const { ts, time, level: _l, logger: _g, msg: _m, message: _mm, ...extras } = log; + const extraFields = Object.entries(extras) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(' '); + + const prefix = timestamp ? `[${timestamp}]` : ''; + const text = `${prefix} ${level} ${logger} ${msg}${extraFields ? ' ' + extraFields : ''}`; + + const color = COLORS[level] ?? COLORS['DEFAULT']; + return `${color}${text}${RESET}`; + } catch { + const guessedLevel = toLevel(line); + const color = COLORS[guessedLevel] ?? COLORS['DEFAULT']; + return `${color}${line}${RESET}`; + } +} diff --git a/src/app/shared/utils/notification.ts b/src/app/shared/utils/notification.ts new file mode 100644 index 0000000..e6ec039 --- /dev/null +++ b/src/app/shared/utils/notification.ts @@ -0,0 +1,29 @@ +import { NotificationType } from '../../core/models'; + +/** + * Returns the corresponding Bootstrap text class and Material icon name + * for a given notification type. + * + * @param type - The notification type (Success, Error, Info, or Warning). + * @returns An object containing: + * - msg: The Bootstrap text color class for the notification. + * - icon: The Material symbol icon name for the notification. + * + * @example + * const props = getClass(NotificationType.Success); + * // props: { msg: 'text-success', icon: 'check_circle' } + */ +export function getClass(type: NotificationType): { msg: string; icon: string } { + switch (type) { + case NotificationType.Success: + return { msg: 'text-success', icon: 'check_circle' }; + case NotificationType.Error: + return { msg: 'text-danger', icon: 'error' }; + case NotificationType.Info: + return { msg: 'text-info', icon: 'info' }; + case NotificationType.Warning: + return { msg: 'text-warning', icon: 'warning' }; + default: + return { msg: 'text-warning', icon: 'warning' }; + } +} diff --git a/src/app/shared/utils/release.ts b/src/app/shared/utils/release.ts index 9d45c02..9a5a7f1 100644 --- a/src/app/shared/utils/release.ts +++ b/src/app/shared/utils/release.ts @@ -1,3 +1,4 @@ +import { stringify } from 'yaml'; import { REGISTRY_REPO_URL_PATTERN } from '../../core/constants'; export interface Parameter { @@ -32,17 +33,102 @@ export function deflateParameters(params: Parameter[]): Record { } /** - * Extracts the service name (the last path segment) from a registry URL. + * Extracts the package name (the last path segment) from a registry URL. * * For example: * - quay.io/kubocd/packages/cert-manager → "cert-manager" * - quay.io/kubocd/packages/cert-manager:v1234 → "cert-manager" - * - https://quay.io/org/service:tag → "service" + * - https://quay.io/org/package:tag → "package" * * @param url - The registry URL string (with or without tag) - * @returns The service name, or null if no match is found + * @returns The package name, or null if no match is found */ -export function extractService(url: string): string { +export function extractPackage(url: string): string { const match = url.match(REGISTRY_REPO_URL_PATTERN); return match ? match[1] : ''; } + +/** + * Picks only the specified allowed fields from an object, returning a new object. + * + * This is useful for stripping UI-only or extended fields from an object + * (such as removing all non-Release fields from a ReleaseInstance). + * + * @param obj The original object to pick fields from. + * @param allowedFields An array of string keys that should be retained in the new object. + * @returns A shallow copy containing only the allowed fields present in the original object. + * + * @example + * const releaseKeys = ['apiVersion', 'kind', 'metadata', 'spec', 'status']; + * const clean = pickFields(instance, releaseKeys); + */ +export function pickFields(obj: T, allowedFields: string[]): Partial { + const result: Partial = {}; + for (const key of allowedFields) { + if (key in obj) { + result[key as keyof T] = obj[key as keyof T]; + } + } + return result; +} + +/** + * Copy any JS value to the clipboard as YAML. + * + * @param data - Any serializable value (object/array/primitive) to encode as YAML. + */ +export async function copyToClipboardYaml(data: any): Promise { + const yaml = stringify(data ?? {}); + const secureClipboard = + typeof navigator !== 'undefined' && + !!navigator.clipboard && + (window.isSecureContext ?? location.protocol === 'https:'); + + if (secureClipboard) { + try { + await navigator.clipboard.writeText(yaml); + return; + } catch { + // fallback below + } + } + + const ta = document.createElement('textarea'); + ta.value = yaml; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } +} + +/** + * Download any JS value as a .yaml file. + * + * @param data - Any serializable value to encode as YAML. + * @param options.filename - File name (default: "data.yaml"). + * @param options.mimeType - MIME type (default: "text/yaml;charset=utf-8"). + */ +export function downloadYaml(data: any, options?: { filename?: string; mimeType?: string }): void { + const { filename = 'data.yaml', mimeType = 'text/yaml;charset=utf-8' } = options ?? {}; + const yaml = stringify(data ?? {}); + const blob = new Blob([yaml], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = sanitizeFilename(filename); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_'); +} diff --git a/src/app/shared/utils/tooltip.ts b/src/app/shared/utils/tooltip.ts new file mode 100644 index 0000000..bcec0c8 --- /dev/null +++ b/src/app/shared/utils/tooltip.ts @@ -0,0 +1,66 @@ +import { MatTooltip } from '@angular/material/tooltip'; + +/** + * Temporarily replaces a MatTooltip's text, forces a refresh immediately, + * then restores it after a delay. + * + * - If the tooltip is currently visible, it will be re-shown with the new text. + * - You can keep an external binding (e.g., `[matTooltip]="copyTooltip"`) in sync + * by passing `updateBinding`, which will be called with both the temporary and + * restored text values. + * + * @param tip The MatTooltip instance obtained from the template reference. + * @param tempText The temporary text to show (e.g., "Copied!"). + * @param resetText The text to restore to after the delay (e.g., "Copy to clipboard"). + * @param options + * - duration How long to keep `tempText` before restoring (ms). Default: 1000. + * - show Whether to show the tooltip immediately after swapping text. Default: true. + * - updateBinding Optional callback to update your component's tooltip binding string. + */ +export function flashTooltip( + tip: MatTooltip, + tempText: string, + resetText: string, + options?: { + duration?: number; + show?: boolean; + updateBinding?: (text: string) => void; + } +): void { + const { duration = 1000, show = true, updateBinding } = options ?? {}; + + // Set temporary text and refresh immediately + tip.hide(0); + tip.message = tempText; + updateBinding?.(tempText); + if (show) tip.show(); + + // Restore after the delay + window.setTimeout(() => { + tip.hide(0); + tip.message = resetText; + updateBinding?.(resetText); + // Optionally: tip.show(); // if you want to show again after reset + }, duration); +} + +/** + * Convenience helper specifically for "Copied!" UX. + * + * @param tip The MatTooltip instance. + * @param options + * - resetText Text to restore to. Default: "Copy to clipboard". + * - duration Delay in ms before restoring. Default: 1000. + * - updateBinding Callback to keep your `[matTooltip]` binding in sync. + */ +export function showCopiedTooltip( + tip: MatTooltip, + options?: { + resetText?: string; + duration?: number; + updateBinding?: (text: string) => void; + } +): void { + const { resetText = 'Copy to clipboard', duration = 1000, updateBinding } = options ?? {}; + flashTooltip(tip, 'Copied!', resetText, { duration, show: true, updateBinding }); +} diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts index 7c88396..6404caa 100644 --- a/src/environments/environment.dev.ts +++ b/src/environments/environment.dev.ts @@ -1,5 +1,5 @@ export const environment = { - version: '0.3.0', + version: '0.4.0', production: false, logging: { enableConsole: true, diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index a560ca1..7c398ab 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,5 +1,5 @@ export const environment = { - version: '0.3.0', + version: '0.4.0', production: true, logging: { enableConsole: true, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index dfe7a12..8ab86bd 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ export const environment = { - version: '0.3.0', + version: '0.4.0', production: false, appConfig: { filePath: '/config/app.config.json', diff --git a/src/styles.scss b/src/styles.scss index 74ec09c..d84546a 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -165,7 +165,6 @@ hr { flex-direction: row; align-items: center; min-height: 48px; - padding: 0 1rem; overflow: hidden; } @@ -186,6 +185,7 @@ hr { overflow-y: auto; overflow-x: hidden; padding-bottom: 10px; + box-sizing: border-box; } } } @@ -374,8 +374,8 @@ button.close-icon{ } .nav-tabs .nav-link.active { - border-bottom: 1px solid !important; - border-bottom-color: var(--text-okdp) !important; + // border-bottom: 1px solid !important; + // border-bottom-color: var(--text-okdp) !important; color: var(--text-okdp) !important; } @@ -399,7 +399,7 @@ button.close-icon{ .card { margin: auto; border: unset; - background-color: var(--card-bg-color) !important; + background-color: var(--card-bg-color); } .card:hover { @@ -568,7 +568,7 @@ button.close-icon{ } .text-primary { - font-size: var(--primary-font-size) !important; + font-size: var(--primary-font-size); font-weight: normal !important; line-height: 21px !important; color: var(--text-primary-color) !important; @@ -635,6 +635,10 @@ button.close-icon{ color: var(--text-secondary-color) !important; } +.small { + font-size: 70% !important; +} + .okdp-bg-primary { background-color: var(--bg-secondary-color) !important; } @@ -775,6 +779,76 @@ button:active .icon-outlined { color: var(--text-okdp) !important; } +.cursor-pointer { + cursor: pointer; +} + +.border-row { + border-bottom: 1px solid var(--border-color-translucent); +} + + +// Tables +td, th { + background-color: transparent !important; + border-bottom: 1px solid var(--border-color-translucent); +} + +// ========================================== +// Bootsrap ================================ +// ========================================== +.accordion, .accordion-header { + background-color: var(--card-bg-color) !important; +} + +.accordion-item { + border: 1px solid var(--border-color-translucent) !important; + border-radius: 1px solid var(--border-color-translucent) !important; + background-color: var(--card-bg-color) !important; +} + +.accordion-button { + background-color: var(--card-bg-color) !important; + color: inherit !important; + box-shadow: none !important; +} + +.accordion-button::after { + content: "▼"; + font-size: var(--primary-font-size) !important; + font-weight: normal; + line-height: 21px; + color: var(--text-secondary-color) !important; + width: 1.25rem !important; + height: 1.25rem !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: transform 0.2s !important; + background-image: none !important; + background-color: transparent !important; +} + +.accordion-button:focus, +.accordion-button:hover { + background-color: transparent !important; + color: inherit !important; + box-shadow: none !important; +} + +.accordion-button:not(.collapsed)::after { + transform: rotate(180deg); +} + +.alert-info { + font-size: var(--primary-font-size) !important; + font-weight: normal; + line-height: 21px; + color: var(--text-secondary-color) !important; + background-color: var(--card-bg-color) !important;; + border: 1px solid var(--border-color-translucent) !important; +} + // ========================================== // Angular Material ======================== // ========================================== @@ -1125,7 +1199,7 @@ mat-row:hover { } // KuboCD Status -.bg-ready { +.bg-ready, .bg-completed, .bg-healthy, .bg-running { background-color: var(--bs-success) !important; color: #fff !important; } @@ -1145,7 +1219,7 @@ mat-row:hover { color: #000 !important; } -.bg-terminating { +.bg-terminating, .bg-terminated { background-color: var(--bs-warning) !important; color: #000 !important; } @@ -1155,12 +1229,12 @@ mat-row:hover { color: #fff !important; } -.bg-wait_hrel { +.bg-wait_hrel, .bg-pending, .bg-waiting { background-color: var(--bs-primary) !important; color: #fff !important; } -.bg-error { +.bg-error, .bg-failed, .bg-notready { background-color: var(--bs-danger) !important; color: #fff !important; } @@ -1170,7 +1244,7 @@ mat-row:hover { color: #fff !important; } -.text-ready { +.text-ready, .text-running, .text-healthy, .text-completed, .text-succeeded { @extend .text-success; color: var(--bs-success) !important; } @@ -1190,7 +1264,7 @@ mat-row:hover { color: var(--bs-warning) !important; } -.text-terminating { +.text-terminating, .text-terminated { @extend .text-warning; color: var(--bs-warning) !important; } @@ -1200,12 +1274,12 @@ mat-row:hover { color: var(--bs-secondary) !important; } -.text-wait_hrel { +.text-wait_hrel, .text-pending, .text-waiting { @extend .text-primary; color: var(--bs-primary) !important; } -.text-error { +.text-error, .text-notready, .text-failed { @extend .text-danger; color: var(--bs-danger) !important; }