From 35dd771f6393b835e6921de9807bec88a749505e Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 15 May 2026 15:56:33 +0330 Subject: [PATCH] Add unified logs platform with Helm-managed central Elasticsearch. Deploy cloudhost-logging on cluster registration, ship app and optional service logs to ES with owner isolation, and fix Kibana 8.12 auth via kibana_system. Co-authored-by: Cursor --- .../cloudhost-app/templates/_log-shipper.tpl | 46 ++ .../templates/db-deployment.yaml | 2 + .../templates/fluent-bit-configmap.yaml | 4 + .../templates/log-shipper-configmap.yaml | 151 +++++ .../templates/rabbitmq-deployment.yaml | 2 + .../templates/redis-deployment.yaml | 2 + backend/helm/cloudhost-app/values.yaml | 2 + backend/helm/cloudhost-logging/Chart.yaml | 6 + .../cloudhost-logging/templates/_helpers.tpl | 7 + .../templates/elasticsearch-pvc.yaml | 17 + .../templates/elasticsearch-service.yaml | 18 + .../templates/elasticsearch-statefulset.yaml | 99 +++ .../templates/kibana-deployment.yaml | 96 +++ .../templates/kibana-service.yaml | 14 + .../templates/namespace.yaml | 7 + .../cloudhost-logging/templates/secret.yaml | 13 + backend/helm/cloudhost-logging/values.yaml | 34 + .../src/applications/applications.service.ts | 2 +- backend/src/clusters/clusters.module.ts | 8 +- backend/src/clusters/clusters.service.ts | 10 +- backend/src/config/configuration.ts | 6 + .../src/kubernetes/elasticsearch.service.ts | 630 +++++++++--------- backend/src/kubernetes/helm.service.ts | 94 ++- backend/src/kubernetes/kubernetes.module.ts | 4 +- backend/src/kubernetes/kubernetes.service.ts | 165 ++++- backend/src/kubernetes/logs.controller.ts | 427 ++++-------- .../src/app/dashboard/admin/clusters/page.tsx | 98 ++- frontend/src/app/dashboard/apps/[id]/page.tsx | 11 +- frontend/src/app/dashboard/layout.tsx | 2 + frontend/src/app/dashboard/logs/page.tsx | 588 ++++++++-------- frontend/src/types/index.ts | 30 + 31 files changed, 1657 insertions(+), 938 deletions(-) create mode 100644 backend/helm/cloudhost-app/templates/_log-shipper.tpl create mode 100644 backend/helm/cloudhost-app/templates/log-shipper-configmap.yaml create mode 100644 backend/helm/cloudhost-logging/Chart.yaml create mode 100644 backend/helm/cloudhost-logging/templates/_helpers.tpl create mode 100644 backend/helm/cloudhost-logging/templates/elasticsearch-pvc.yaml create mode 100644 backend/helm/cloudhost-logging/templates/elasticsearch-service.yaml create mode 100644 backend/helm/cloudhost-logging/templates/elasticsearch-statefulset.yaml create mode 100644 backend/helm/cloudhost-logging/templates/kibana-deployment.yaml create mode 100644 backend/helm/cloudhost-logging/templates/kibana-service.yaml create mode 100644 backend/helm/cloudhost-logging/templates/namespace.yaml create mode 100644 backend/helm/cloudhost-logging/templates/secret.yaml create mode 100644 backend/helm/cloudhost-logging/values.yaml diff --git a/backend/helm/cloudhost-app/templates/_log-shipper.tpl b/backend/helm/cloudhost-app/templates/_log-shipper.tpl new file mode 100644 index 0000000..ddd2d65 --- /dev/null +++ b/backend/helm/cloudhost-app/templates/_log-shipper.tpl @@ -0,0 +1,46 @@ +{{/* + Optional log-shipper sidecar for Redis / RabbitMQ / Database pods. + Requires .workloadName, .workloadType (redis|rabbitmq|database), and root context . +*/}} +{{- define "cloudhost-app.logShipperContainers" -}} +{{- if .root.Values.elasticsearch.enabled }} +- name: log-shipper + image: fluent/fluent-bit:2.2 + resources: + requests: + cpu: "10m" + memory: "32Mi" + limits: + cpu: "50m" + memory: "64Mi" + volumeMounts: + - name: varlogpods + mountPath: /var/log/pods + readOnly: true + - name: log-shipper-config + mountPath: /fluent-bit/etc + env: + - name: ES_HOST + value: "elasticsearch.logging.svc.cluster.local" + - name: ES_PORT + value: "9200" + - name: ES_PASSWORD + valueFrom: + secretKeyRef: + name: elasticsearch-credentials + key: ELASTIC_PASSWORD + optional: true +{{- end }} +{{- end }} + +{{- define "cloudhost-app.logShipperVolumes" -}} +{{- if .root.Values.elasticsearch.enabled }} +- name: varlogpods + hostPath: + path: /var/log/pods + type: Directory +- name: log-shipper-config + configMap: + name: {{ .workloadName }}-log-shipper-config +{{- end }} +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/db-deployment.yaml b/backend/helm/cloudhost-app/templates/db-deployment.yaml index 6ce7721..17bd709 100644 --- a/backend/helm/cloudhost-app/templates/db-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/db-deployment.yaml @@ -138,8 +138,10 @@ spec: initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 5 + {{- include "cloudhost-app.logShipperContainers" (dict "root" . "workloadName" $dbName "workloadType" "database") | nindent 8 }} volumes: - name: db-storage persistentVolumeClaim: claimName: {{ $dbName }} + {{- include "cloudhost-app.logShipperVolumes" (dict "root" . "workloadName" $dbName) | nindent 8 }} {{- end }} diff --git a/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml b/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml index 9edb73c..d3a051a 100644 --- a/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml +++ b/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml @@ -31,8 +31,12 @@ data: Name record_modifier Match * Record app {{ $name }} + Record applicationName {{ $name }} Record namespace {{ $ns }} Record runtime {{ .Values.app.runtime }} + Record ownerId {{ .Values.elasticsearch.ownerId }} + Record applicationId {{ .Values.elasticsearch.applicationId }} + Record workload app [FILTER] Name parser diff --git a/backend/helm/cloudhost-app/templates/log-shipper-configmap.yaml b/backend/helm/cloudhost-app/templates/log-shipper-configmap.yaml new file mode 100644 index 0000000..626c8c0 --- /dev/null +++ b/backend/helm/cloudhost-app/templates/log-shipper-configmap.yaml @@ -0,0 +1,151 @@ +{{- if .Values.elasticsearch.enabled }} +{{- $name := include "cloudhost-app.name" . -}} +{{- $ns := include "cloudhost-app.namespace" . -}} +{{- if .Values.redis.enabled }} +{{- $redisName := printf "%s-redis" $name -}} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $redisName }}-log-shipper-config + namespace: {{ $ns }} +data: + fluent-bit.conf: | + [SERVICE] + Flush 5 + Daemon Off + Log_Level info + Parsers_File /fluent-bit/etc/parsers.conf + [INPUT] + Name tail + Path /var/log/pods/*{{ $redisName }}*/*/*.log + Tag redis.{{ $redisName }} + Refresh_Interval 5 + Parser docker + [FILTER] + Name record_modifier + Match * + Record app {{ $name }} + Record applicationName {{ $name }} + Record namespace {{ $ns }} + Record ownerId {{ .Values.elasticsearch.ownerId }} + Record applicationId {{ .Values.elasticsearch.applicationId }} + Record workload redis + [OUTPUT] + Name es + Match * + Host ${ES_HOST} + Port ${ES_PORT} + HTTP_User elastic + HTTP_Passwd ${ES_PASSWORD} + Index logs-{{ $ns }}-{{ $name }} + Logstash_Format On + Logstash_Prefix logs-{{ $ns }} + Suppress_Type_Name On + tls Off + parsers.conf: | + [PARSER] + Name docker + Format json + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L +{{- end }} +{{- if .Values.rabbitmq.enabled }} +{{- $rabbitName := printf "%s-rabbitmq" $name -}} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $rabbitName }}-log-shipper-config + namespace: {{ $ns }} +data: + fluent-bit.conf: | + [SERVICE] + Flush 5 + Daemon Off + Log_Level info + Parsers_File /fluent-bit/etc/parsers.conf + [INPUT] + Name tail + Path /var/log/pods/*{{ $rabbitName }}*/*/*.log + Tag rabbitmq.{{ $rabbitName }} + Refresh_Interval 5 + Parser docker + [FILTER] + Name record_modifier + Match * + Record app {{ $name }} + Record applicationName {{ $name }} + Record namespace {{ $ns }} + Record ownerId {{ .Values.elasticsearch.ownerId }} + Record applicationId {{ .Values.elasticsearch.applicationId }} + Record workload rabbitmq + [OUTPUT] + Name es + Match * + Host ${ES_HOST} + Port ${ES_PORT} + HTTP_User elastic + HTTP_Passwd ${ES_PASSWORD} + Index logs-{{ $ns }}-{{ $name }} + Logstash_Format On + Logstash_Prefix logs-{{ $ns }} + Suppress_Type_Name On + tls Off + parsers.conf: | + [PARSER] + Name docker + Format json + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L +{{- end }} +{{- if .Values.database.enabled }} +{{- $dbName := include "cloudhost-app.dbDeploymentName" . -}} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $dbName }}-log-shipper-config + namespace: {{ $ns }} +data: + fluent-bit.conf: | + [SERVICE] + Flush 5 + Daemon Off + Log_Level info + Parsers_File /fluent-bit/etc/parsers.conf + [INPUT] + Name tail + Path /var/log/pods/*{{ $dbName }}*/*/*.log + Tag database.{{ $dbName }} + Refresh_Interval 5 + Parser docker + [FILTER] + Name record_modifier + Match * + Record app {{ $name }} + Record applicationName {{ $name }} + Record namespace {{ $ns }} + Record ownerId {{ .Values.elasticsearch.ownerId }} + Record applicationId {{ .Values.elasticsearch.applicationId }} + Record workload database + [OUTPUT] + Name es + Match * + Host ${ES_HOST} + Port ${ES_PORT} + HTTP_User elastic + HTTP_Passwd ${ES_PASSWORD} + Index logs-{{ $ns }}-{{ $name }} + Logstash_Format On + Logstash_Prefix logs-{{ $ns }} + Suppress_Type_Name On + tls Off + parsers.conf: | + [PARSER] + Name docker + Format json + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L +{{- end }} +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml b/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml index fce15f0..2d15a56 100644 --- a/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml @@ -94,10 +94,12 @@ spec: initialDelaySeconds: 60 periodSeconds: 30 timeoutSeconds: 10 + {{- include "cloudhost-app.logShipperContainers" (dict "root" . "workloadName" $rabbitName "workloadType" "rabbitmq") | nindent 8 }} volumes: - name: rabbitmq-data persistentVolumeClaim: claimName: {{ $rabbitName }}-data + {{- include "cloudhost-app.logShipperVolumes" (dict "root" . "workloadName" $rabbitName) | nindent 8 }} --- apiVersion: v1 kind: Service diff --git a/backend/helm/cloudhost-app/templates/redis-deployment.yaml b/backend/helm/cloudhost-app/templates/redis-deployment.yaml index 6e666f1..7f12014 100644 --- a/backend/helm/cloudhost-app/templates/redis-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/redis-deployment.yaml @@ -84,10 +84,12 @@ spec: command: ["redis-cli", "ping"] initialDelaySeconds: 15 periodSeconds: 20 + {{- include "cloudhost-app.logShipperContainers" (dict "root" . "workloadName" $redisName "workloadType" "redis") | nindent 8 }} volumes: - name: redis-data persistentVolumeClaim: claimName: {{ $redisName }}-data + {{- include "cloudhost-app.logShipperVolumes" (dict "root" . "workloadName" $redisName) | nindent 8 }} --- apiVersion: v1 kind: Service diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index f264a28..66a7749 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -78,6 +78,8 @@ rabbitmq: elasticsearch: enabled: false logPaths: [] + ownerId: "" + applicationId: "" # ── Change metadata ───────────────────────────────────── changeCause: "" diff --git a/backend/helm/cloudhost-logging/Chart.yaml b/backend/helm/cloudhost-logging/Chart.yaml new file mode 100644 index 0000000..6f91472 --- /dev/null +++ b/backend/helm/cloudhost-logging/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: cloudhost-logging +description: CloudHost central logging stack (Elasticsearch + Kibana) +type: application +version: 0.1.0 +appVersion: "8.12.0" diff --git a/backend/helm/cloudhost-logging/templates/_helpers.tpl b/backend/helm/cloudhost-logging/templates/_helpers.tpl new file mode 100644 index 0000000..1a98ea0 --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/_helpers.tpl @@ -0,0 +1,7 @@ +{{- define "cloudhost-logging.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cloudhost-logging.namespace" -}} +{{- .Values.namespace | default "logging" }} +{{- end }} diff --git a/backend/helm/cloudhost-logging/templates/elasticsearch-pvc.yaml b/backend/helm/cloudhost-logging/templates/elasticsearch-pvc.yaml new file mode 100644 index 0000000..5320963 --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/elasticsearch-pvc.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: elasticsearch-data + namespace: {{ include "cloudhost-logging.namespace" . }} + labels: + app: elasticsearch + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.storage }} diff --git a/backend/helm/cloudhost-logging/templates/elasticsearch-service.yaml b/backend/helm/cloudhost-logging/templates/elasticsearch-service.yaml new file mode 100644 index 0000000..4b6e82c --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/elasticsearch-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: elasticsearch + namespace: {{ include "cloudhost-logging.namespace" . }} + labels: + app: elasticsearch +spec: + type: ClusterIP + selector: + app: elasticsearch + ports: + - name: http + port: 9200 + targetPort: 9200 + - name: transport + port: 9300 + targetPort: 9300 diff --git a/backend/helm/cloudhost-logging/templates/elasticsearch-statefulset.yaml b/backend/helm/cloudhost-logging/templates/elasticsearch-statefulset.yaml new file mode 100644 index 0000000..99ccee3 --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/elasticsearch-statefulset.yaml @@ -0,0 +1,99 @@ +{{- $auth := printf "elastic:%s" .Values.elasticPassword | b64enc }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: elasticsearch + namespace: {{ include "cloudhost-logging.namespace" . }} + labels: + app: elasticsearch +spec: + serviceName: elasticsearch + replicas: 1 + selector: + matchLabels: + app: elasticsearch + template: + metadata: + labels: + app: elasticsearch + spec: + securityContext: + fsGroup: 1000 + initContainers: + - name: fix-permissions + image: {{ .Values.images.busybox }} + command: + - sh + - -c + - chown -R 1000:1000 /usr/share/elasticsearch/data + securityContext: + runAsUser: 0 + privileged: true + volumeMounts: + - name: es-data + mountPath: /usr/share/elasticsearch/data + - name: increase-vm-max-map + image: {{ .Values.images.busybox }} + command: + - sysctl + - -w + - vm.max_map_count=262144 + securityContext: + privileged: true + containers: + - name: elasticsearch + image: {{ .Values.images.elasticsearch }} + ports: + - containerPort: 9200 + name: http + - containerPort: 9300 + name: transport + env: + - name: discovery.type + value: single-node + - name: xpack.security.enabled + value: "true" + - name: xpack.security.http.ssl.enabled + value: "false" + - name: xpack.security.transport.ssl.enabled + value: "false" + - name: ELASTIC_PASSWORD + valueFrom: + secretKeyRef: + name: elasticsearch-credentials + key: ELASTIC_PASSWORD + - name: ES_JAVA_OPTS + value: {{ .Values.elasticsearch.javaOpts | quote }} + - name: cluster.name + value: {{ .Values.clusterName | quote }} + - name: bootstrap.memory_lock + value: "false" + resources: + {{- toYaml .Values.elasticsearch.resources | nindent 12 }} + volumeMounts: + - name: es-data + mountPath: /usr/share/elasticsearch/data + readinessProbe: + httpGet: + path: /_cluster/health?local=true + port: 9200 + httpHeaders: + - name: Authorization + value: Basic {{ $auth }} + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /_cluster/health?local=true + port: 9200 + httpHeaders: + - name: Authorization + value: Basic {{ $auth }} + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + volumes: + - name: es-data + persistentVolumeClaim: + claimName: elasticsearch-data diff --git a/backend/helm/cloudhost-logging/templates/kibana-deployment.yaml b/backend/helm/cloudhost-logging/templates/kibana-deployment.yaml new file mode 100644 index 0000000..ae64778 --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/kibana-deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kibana + namespace: {{ include "cloudhost-logging.namespace" . }} + labels: + app: kibana +spec: + replicas: 1 + selector: + matchLabels: + app: kibana + template: + metadata: + labels: + app: kibana + spec: + initContainers: + - name: setup-kibana-system-user + image: {{ .Values.images.curl | default "curlimages/curl:8.5.0" }} + env: + - name: ELASTIC_PASSWORD + valueFrom: + secretKeyRef: + name: elasticsearch-credentials + key: ELASTIC_PASSWORD + - name: KIBANA_SYSTEM_PASSWORD + valueFrom: + secretKeyRef: + name: elasticsearch-credentials + key: KIBANA_SYSTEM_PASSWORD + command: + - sh + - -ec + - | + echo "Waiting for Elasticsearch..." + until curl -sf -u "elastic:${ELASTIC_PASSWORD}" \ + "http://elasticsearch:9200/_cluster/health?wait_for_status=yellow&timeout=60s"; do + sleep 5 + done + echo "Setting kibana_system password..." + HTTP_CODE=$(curl -s -o /tmp/curl-out -w "%{http_code}" -X POST \ + -u "elastic:${ELASTIC_PASSWORD}" \ + "http://elasticsearch:9200/_security/user/kibana_system/_password" \ + -H "Content-Type: application/json" \ + -d "{\"password\":\"${KIBANA_SYSTEM_PASSWORD}\"}") + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then + echo "kibana_system password setup failed (HTTP $HTTP_CODE):" + cat /tmp/curl-out + exit 1 + fi + echo "kibana_system user ready" + echo "Configuring single-node index settings..." + curl -sf -X PUT -u "elastic:${ELASTIC_PASSWORD}" \ + "http://elasticsearch:9200/_index_template/single-node-zero-replicas" \ + -H "Content-Type: application/json" \ + -d '{"index_patterns":["*"],"priority":1,"template":{"settings":{"index.number_of_replicas":0}}}' + curl -sf -X PUT -u "elastic:${ELASTIC_PASSWORD}" \ + "http://elasticsearch:9200/.kibana*/_settings" \ + -H "Content-Type: application/json" \ + -d '{"index":{"number_of_replicas":0}}' || true + containers: + - name: kibana + image: {{ .Values.images.kibana }} + ports: + - containerPort: 5601 + env: + - name: ELASTICSEARCH_HOSTS + value: http://elasticsearch:9200 + - name: ELASTICSEARCH_USERNAME + value: kibana_system + - name: ELASTICSEARCH_PASSWORD + valueFrom: + secretKeyRef: + name: elasticsearch-credentials + key: KIBANA_SYSTEM_PASSWORD + - name: SERVER_NAME + value: kibana + - name: XPACK_SECURITY_ENABLED + value: "true" + resources: + {{- toYaml .Values.kibana.resources | nindent 12 }} + readinessProbe: + httpGet: + path: /api/status + port: 5601 + initialDelaySeconds: 45 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /api/status + port: 5601 + initialDelaySeconds: 90 + periodSeconds: 30 + timeoutSeconds: 5 diff --git a/backend/helm/cloudhost-logging/templates/kibana-service.yaml b/backend/helm/cloudhost-logging/templates/kibana-service.yaml new file mode 100644 index 0000000..1557e4f --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/kibana-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: kibana + namespace: {{ include "cloudhost-logging.namespace" . }} + labels: + app: kibana +spec: + type: ClusterIP + selector: + app: kibana + ports: + - port: 5601 + targetPort: 5601 diff --git a/backend/helm/cloudhost-logging/templates/namespace.yaml b/backend/helm/cloudhost-logging/templates/namespace.yaml new file mode 100644 index 0000000..28477f8 --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "cloudhost-logging.namespace" . }} + labels: + app.kubernetes.io/managed-by: cloudhost + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/backend/helm/cloudhost-logging/templates/secret.yaml b/backend/helm/cloudhost-logging/templates/secret.yaml new file mode 100644 index 0000000..0de2837 --- /dev/null +++ b/backend/helm/cloudhost-logging/templates/secret.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: elasticsearch-credentials + namespace: {{ include "cloudhost-logging.namespace" . }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/instance: {{ .Release.Name }} +type: Opaque +stringData: + ELASTIC_PASSWORD: {{ required "elasticPassword is required" .Values.elasticPassword | quote }} + FLUENTBIT_PASSWORD: {{ required "fluentbitPassword is required" .Values.fluentbitPassword | quote }} + KIBANA_SYSTEM_PASSWORD: {{ required "kibanaSystemPassword is required" .Values.kibanaSystemPassword | quote }} diff --git a/backend/helm/cloudhost-logging/values.yaml b/backend/helm/cloudhost-logging/values.yaml new file mode 100644 index 0000000..a691d13 --- /dev/null +++ b/backend/helm/cloudhost-logging/values.yaml @@ -0,0 +1,34 @@ +namespace: logging + +elasticPassword: "" +fluentbitPassword: "" +kibanaSystemPassword: "" + +clusterName: cloudhost-logs + +storage: 50Gi + +images: + elasticsearch: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + kibana: docker.elastic.co/kibana/kibana:8.12.0 + busybox: busybox:1.36 + curl: curlimages/curl:8.5.0 + +elasticsearch: + javaOpts: "-Xms1g -Xmx1g" + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: 2000m + memory: 4Gi + +kibana: + resources: + requests: + cpu: 50m + memory: 384Mi + limits: + cpu: 500m + memory: 768Mi diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index e25ce30..0542f73 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -104,7 +104,7 @@ export class ApplicationsService { subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, - envVars: dto.envVars, + envVars: dto.envVars ?? {}, }, platformDomain, ), diff --git a/backend/src/clusters/clusters.module.ts b/backend/src/clusters/clusters.module.ts index c6600c6..934b463 100644 --- a/backend/src/clusters/clusters.module.ts +++ b/backend/src/clusters/clusters.module.ts @@ -1,12 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ClustersService } from './clusters.service'; import { ClustersController } from './clusters.controller'; import { Cluster } from './entities/cluster.entity'; import { ClusterPool } from './entities/cluster-pool.entity'; +import { KubernetesModule } from '../kubernetes/kubernetes.module'; @Module({ - imports: [TypeOrmModule.forFeature([Cluster, ClusterPool])], + imports: [ + TypeOrmModule.forFeature([Cluster, ClusterPool]), + forwardRef(() => KubernetesModule), + ], controllers: [ClustersController], providers: [ClustersService], exports: [ClustersService], diff --git a/backend/src/clusters/clusters.service.ts b/backend/src/clusters/clusters.service.ts index fde93d8..0f1032c 100644 --- a/backend/src/clusters/clusters.service.ts +++ b/backend/src/clusters/clusters.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger, BadRequestException, Inject, forwardRef } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { Repository, DataSource, In } from 'typeorm'; @@ -8,6 +8,7 @@ import { ClusterPool } from './entities/cluster-pool.entity'; import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto'; import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto'; import { ClusterStatus } from '../common/enums'; +import { ElasticsearchService } from '../kubernetes/elasticsearch.service'; @Injectable() export class ClustersService { @@ -22,6 +23,8 @@ export class ClustersService { private poolsRepository: Repository, private dataSource: DataSource, private configService: ConfigService, + @Inject(forwardRef(() => ElasticsearchService)) + private elasticsearchService: ElasticsearchService, ) {} /** @@ -81,6 +84,11 @@ export class ClustersService { this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`); }); + // Deploy central logging (Elasticsearch + Kibana) via Helm + this.elasticsearchService.deploy(saved.id).catch((err) => { + this.logger.error(`Failed to deploy central logging on "${saved.name}": ${err.message}`); + }); + return saved; } diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index d40f8da..6f4a73d 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -34,6 +34,12 @@ export default () => ({ serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder', }, + elasticsearch: { + password: process.env.ELASTIC_PASSWORD || 'CloudHost2024!Secure', + fluentbitPassword: process.env.FLUENTBIT_PASSWORD || 'FluentBit2024!Writer', + kibanaPassword: process.env.KIBANA_SYSTEM_PASSWORD || 'Kibana2024!System', + }, + platform: { domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local', uploadDir: process.env.UPLOAD_DIR || './uploads', diff --git a/backend/src/kubernetes/elasticsearch.service.ts b/backend/src/kubernetes/elasticsearch.service.ts index 3fb211a..efd64f8 100644 --- a/backend/src/kubernetes/elasticsearch.service.ts +++ b/backend/src/kubernetes/elasticsearch.service.ts @@ -1,21 +1,53 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as k8s from '@kubernetes/client-node'; import * as crypto from 'crypto'; import { ClustersService } from '../clusters/clusters.service'; +import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service'; interface ElasticsearchCredentials { username: string; password: string; } -interface LogEntry { +export interface LogSearchFilters { + applicationId?: string; + applicationName?: string; + workload?: string; + level?: string; + from?: string; + to?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface NormalizedLogEntry { + id: string; timestamp: string; level: string; message: string; - app: string; - namespace: string; - [key: string]: any; + applicationId?: string; + applicationName?: string; + workload?: string; + namespace?: string; + pod?: string; +} + +export interface LogSearchResult { + hits: NormalizedLogEntry[]; + total: number; + page: number; + limit: number; +} + +export interface LogStatsResult { + total: number; + errors: number; + warnings: number; + byLevel: Record; + byWorkload: Record; + period: string; } /** @@ -33,13 +65,17 @@ export class ElasticsearchService { // Default credentials - should be overridden via env in production private readonly ELASTIC_PASSWORD: string; private readonly FLUENTBIT_PASSWORD: string; + private readonly KIBANA_SYSTEM_PASSWORD: string; constructor( + @Inject(forwardRef(() => ClustersService)) private clustersService: ClustersService, private configService: ConfigService, + private helmService: HelmService, ) { this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure'; this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer'; + this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System'; } private async getK8sClients(clusterId?: string) { @@ -133,25 +169,20 @@ export class ElasticsearchService { } /** - * Deploy central Elasticsearch + Kibana stack - * This should be called once per cluster by admin + * Deploy central Elasticsearch + Kibana stack via Helm. */ async deploy(clusterId?: string): Promise<{ esPassword: string; kibanaUrl: string }> { - const { coreApi, appsApi } = await this.getK8sClients(clusterId); + const { cluster } = await this.getK8sClients(clusterId); - // 1. Create logging namespace - await this.ensureNamespace(coreApi); + await this.helmService.installLoggingStack(cluster.kubeconfig, { + elasticPassword: this.ELASTIC_PASSWORD, + fluentbitPassword: this.FLUENTBIT_PASSWORD, + kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD, + }); - // 2. Create credentials secret - await this.createCredentialsSecret(coreApi); - - // 3. Deploy Elasticsearch - await this.deployElasticsearch(coreApi, appsApi); - - // 4. Deploy Kibana - await this.deployKibana(coreApi, appsApi); - - this.logger.log('Central Elasticsearch stack deployed successfully'); + this.logger.log( + `Central logging stack deployed via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`, + ); return { esPassword: this.ELASTIC_PASSWORD, @@ -160,300 +191,22 @@ export class ElasticsearchService { } /** - * Undeploy Elasticsearch stack + * Undeploy Elasticsearch stack (Helm release; PVC retained by chart policy). */ async undeploy(clusterId?: string): Promise { - const { coreApi, appsApi } = await this.getK8sClients(clusterId); + const { cluster } = await this.getK8sClients(clusterId); try { - // Delete Kibana - await appsApi.deleteNamespacedDeployment(this.KIBANA_NAME, this.ES_NAMESPACE); - await coreApi.deleteNamespacedService(this.KIBANA_NAME, this.ES_NAMESPACE); - this.logger.log('Kibana deleted'); + await this.helmService.uninstall(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig); + this.logger.log('Elasticsearch stack undeployed via Helm (PVC preserved)'); } catch (e: any) { - if (e?.response?.statusCode !== 404) { - this.logger.warn(`Failed to delete Kibana: ${e.message}`); + const msg = e?.message || String(e); + if (msg.includes('not found')) { + this.logger.log('Logging Helm release not found — nothing to undeploy'); + return; } + throw e; } - - try { - // Delete Elasticsearch - await appsApi.deleteNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE); - await coreApi.deleteNamespacedService(this.ES_NAME, this.ES_NAMESPACE); - this.logger.log('Elasticsearch deleted'); - } catch (e: any) { - if (e?.response?.statusCode !== 404) { - this.logger.warn(`Failed to delete Elasticsearch: ${e.message}`); - } - } - - // Note: PVC is kept intentionally to preserve data - this.logger.log('Elasticsearch stack undeployed (PVC preserved)'); - } - - private async ensureNamespace(coreApi: k8s.CoreV1Api): Promise { - try { - await coreApi.readNamespace(this.ES_NAMESPACE); - } catch { - await coreApi.createNamespace({ - metadata: { - name: this.ES_NAMESPACE, - labels: { 'app.kubernetes.io/managed-by': 'cloudhost' }, - }, - }); - this.logger.log(`Created namespace: ${this.ES_NAMESPACE}`); - } - } - - private async createCredentialsSecret(coreApi: k8s.CoreV1Api): Promise { - const secret = { - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: 'elasticsearch-credentials', - namespace: this.ES_NAMESPACE, - }, - type: 'Opaque', - data: { - ELASTIC_PASSWORD: Buffer.from(this.ELASTIC_PASSWORD).toString('base64'), - FLUENTBIT_PASSWORD: Buffer.from(this.FLUENTBIT_PASSWORD).toString('base64'), - }, - }; - - try { - await coreApi.replaceNamespacedSecret('elasticsearch-credentials', this.ES_NAMESPACE, secret); - } catch { - await coreApi.createNamespacedSecret(this.ES_NAMESPACE, secret); - } - } - - private async deployElasticsearch( - coreApi: k8s.CoreV1Api, - appsApi: k8s.AppsV1Api, - ): Promise { - // PVC for Elasticsearch data - const pvc: k8s.V1PersistentVolumeClaim = { - apiVersion: 'v1', - kind: 'PersistentVolumeClaim', - metadata: { name: `${this.ES_NAME}-data`, namespace: this.ES_NAMESPACE }, - spec: { - accessModes: ['ReadWriteOnce'], - resources: { requests: { storage: '50Gi' } }, - }, - }; - - try { - await coreApi.readNamespacedPersistentVolumeClaim(pvc.metadata!.name!, this.ES_NAMESPACE); - } catch { - await coreApi.createNamespacedPersistentVolumeClaim(this.ES_NAMESPACE, pvc); - } - - // Base64 encoded auth header - const authHeader = Buffer.from(`elastic:${this.ELASTIC_PASSWORD}`).toString('base64'); - - // StatefulSet for Elasticsearch - const statefulSet: k8s.V1StatefulSet = { - apiVersion: 'apps/v1', - kind: 'StatefulSet', - metadata: { - name: this.ES_NAME, - namespace: this.ES_NAMESPACE, - labels: { app: this.ES_NAME }, - }, - spec: { - serviceName: this.ES_NAME, - replicas: 1, - selector: { matchLabels: { app: this.ES_NAME } }, - template: { - metadata: { labels: { app: this.ES_NAME } }, - spec: { - securityContext: { fsGroup: 1000 }, - initContainers: [ - { - name: 'fix-permissions', - image: 'busybox:1.36', - command: ['sh', '-c', 'chown -R 1000:1000 /usr/share/elasticsearch/data'], - securityContext: { runAsUser: 0, privileged: true }, - volumeMounts: [{ name: 'es-data', mountPath: '/usr/share/elasticsearch/data' }], - }, - { - name: 'increase-vm-max-map', - image: 'busybox:1.36', - command: ['sysctl', '-w', 'vm.max_map_count=262144'], - securityContext: { privileged: true }, - }, - ], - containers: [ - { - name: 'elasticsearch', - image: 'docker.elastic.co/elasticsearch/elasticsearch:8.12.0', - ports: [ - { containerPort: 9200, name: 'http' }, - { containerPort: 9300, name: 'transport' }, - ], - env: [ - { name: 'discovery.type', value: 'single-node' }, - { name: 'xpack.security.enabled', value: 'true' }, - { name: 'xpack.security.http.ssl.enabled', value: 'false' }, - { name: 'xpack.security.transport.ssl.enabled', value: 'false' }, - { - name: 'ELASTIC_PASSWORD', - valueFrom: { - secretKeyRef: { name: 'elasticsearch-credentials', key: 'ELASTIC_PASSWORD' }, - }, - }, - { name: 'ES_JAVA_OPTS', value: '-Xms1g -Xmx1g' }, - { name: 'cluster.name', value: 'cloudhost-logs' }, - { name: 'bootstrap.memory_lock', value: 'false' }, - ], - resources: { - requests: { cpu: '500m', memory: '2Gi' }, - limits: { cpu: '2000m', memory: '4Gi' }, - }, - volumeMounts: [{ name: 'es-data', mountPath: '/usr/share/elasticsearch/data' }], - readinessProbe: { - httpGet: { - path: '/_cluster/health?local=true', - port: 9200 as any, - httpHeaders: [{ name: 'Authorization', value: `Basic ${authHeader}` }], - }, - initialDelaySeconds: 30, - periodSeconds: 10, - timeoutSeconds: 5, - }, - livenessProbe: { - httpGet: { - path: '/_cluster/health?local=true', - port: 9200 as any, - httpHeaders: [{ name: 'Authorization', value: `Basic ${authHeader}` }], - }, - initialDelaySeconds: 60, - periodSeconds: 30, - timeoutSeconds: 10, - }, - }, - ], - volumes: [ - { - name: 'es-data', - persistentVolumeClaim: { claimName: `${this.ES_NAME}-data` }, - }, - ], - }, - }, - }, - }; - - try { - await appsApi.replaceNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE, statefulSet); - } catch { - await appsApi.createNamespacedStatefulSet(this.ES_NAMESPACE, statefulSet); - } - - // Service for Elasticsearch - const service: k8s.V1Service = { - apiVersion: 'v1', - kind: 'Service', - metadata: { name: this.ES_NAME, namespace: this.ES_NAMESPACE }, - spec: { - selector: { app: this.ES_NAME }, - ports: [ - { port: 9200, targetPort: 9200 as any, name: 'http' }, - { port: 9300, targetPort: 9300 as any, name: 'transport' }, - ], - type: 'ClusterIP', - }, - }; - - try { - await coreApi.replaceNamespacedService(this.ES_NAME, this.ES_NAMESPACE, service); - } catch { - await coreApi.createNamespacedService(this.ES_NAMESPACE, service); - } - - this.logger.log('Elasticsearch deployed'); - } - - private async deployKibana( - coreApi: k8s.CoreV1Api, - appsApi: k8s.AppsV1Api, - ): Promise { - const deployment: k8s.V1Deployment = { - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { - name: this.KIBANA_NAME, - namespace: this.ES_NAMESPACE, - labels: { app: this.KIBANA_NAME }, - }, - spec: { - replicas: 1, - selector: { matchLabels: { app: this.KIBANA_NAME } }, - template: { - metadata: { labels: { app: this.KIBANA_NAME } }, - spec: { - containers: [ - { - name: 'kibana', - image: 'docker.elastic.co/kibana/kibana:8.12.0', - ports: [{ containerPort: 5601 }], - env: [ - { name: 'ELASTICSEARCH_HOSTS', value: `http://${this.ES_NAME}:9200` }, - { name: 'ELASTICSEARCH_USERNAME', value: 'elastic' }, - { - name: 'ELASTICSEARCH_PASSWORD', - valueFrom: { - secretKeyRef: { name: 'elasticsearch-credentials', key: 'ELASTIC_PASSWORD' }, - }, - }, - { name: 'SERVER_NAME', value: 'kibana' }, - { name: 'XPACK_SECURITY_ENABLED', value: 'true' }, - ], - resources: { - requests: { cpu: '200m', memory: '512Mi' }, - limits: { cpu: '1000m', memory: '1Gi' }, - }, - readinessProbe: { - httpGet: { path: '/api/status', port: 5601 as any }, - initialDelaySeconds: 30, - periodSeconds: 10, - }, - livenessProbe: { - httpGet: { path: '/api/status', port: 5601 as any }, - initialDelaySeconds: 60, - periodSeconds: 30, - }, - }, - ], - }, - }, - }, - }; - - try { - await appsApi.replaceNamespacedDeployment(this.KIBANA_NAME, this.ES_NAMESPACE, deployment); - } catch { - await appsApi.createNamespacedDeployment(this.ES_NAMESPACE, deployment); - } - - const service: k8s.V1Service = { - apiVersion: 'v1', - kind: 'Service', - metadata: { name: this.KIBANA_NAME, namespace: this.ES_NAMESPACE }, - spec: { - selector: { app: this.KIBANA_NAME }, - ports: [{ port: 5601, targetPort: 5601 as any }], - type: 'ClusterIP', - }, - }; - - try { - await coreApi.replaceNamespacedService(this.KIBANA_NAME, this.ES_NAMESPACE, service); - } catch { - await coreApi.createNamespacedService(this.ES_NAMESPACE, service); - } - - this.logger.log('Kibana deployed'); } /** @@ -506,20 +259,267 @@ export class ElasticsearchService { } /** - * Build Elasticsearch query to filter logs by user/owner - * Users can only see logs from their own applications + * Build must clauses for user log isolation (new + legacy fields). */ - getUserLogsQuery(userId: string): { query: { bool: { must: any } } } { + buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] { + const userPrefix = userId.split('-')[0]; + const namespace = `user-${userPrefix}`; + + const must: any[] = [ + { + bool: { + should: [ + { term: { ownerId: userId } }, + { term: { 'ownerId.keyword': userId } }, + { term: { namespace } }, + { term: { 'namespace.keyword': namespace } }, + ], + minimum_should_match: 1, + }, + }, + ]; + + if (filters.applicationId) { + must.push({ + bool: { + should: [ + { term: { applicationId: filters.applicationId } }, + { term: { 'applicationId.keyword': filters.applicationId } }, + ], + minimum_should_match: 1, + }, + }); + } + + if (filters.applicationName) { + must.push({ + bool: { + should: [ + { term: { applicationName: filters.applicationName } }, + { term: { 'applicationName.keyword': filters.applicationName } }, + { term: { app: filters.applicationName } }, + { term: { 'app.keyword': filters.applicationName } }, + ], + minimum_should_match: 1, + }, + }); + } + + if (filters.workload) { + must.push({ + bool: { + should: [ + { term: { workload: filters.workload } }, + { term: { 'workload.keyword': filters.workload } }, + ], + minimum_should_match: 1, + }, + }); + } + + if (filters.level) { + must.push({ + bool: { + should: [ + { term: { level: filters.level.toLowerCase() } }, + { term: { 'level.keyword': filters.level.toLowerCase() } }, + ], + minimum_should_match: 1, + }, + }); + } + + if (filters.from || filters.to) { + const rangeFilter: any = { range: { '@timestamp': {} } }; + if (filters.from) rangeFilter.range['@timestamp'].gte = filters.from; + if (filters.to) rangeFilter.range['@timestamp'].lte = filters.to; + must.push(rangeFilter); + } + + if (filters.search) { + must.push({ + multi_match: { + query: filters.search, + fields: ['message', 'log', 'msg', 'error.message'], + type: 'phrase_prefix', + }, + }); + } + + return must; + } + + getUserLogsQuery(userId: string): { query: { bool: { must: any[] } } } { return { query: { bool: { - must: { - term: { - 'kubernetes.labels.owner': userId, - }, - }, + must: this.buildUserLogMustClauses(userId), }, }, }; } + + getUserIndexPattern(userId: string): string { + return `logs-user-${userId.split('-')[0]}-*`; + } + + private async esRequest(path: string, body: unknown, clusterId?: string): Promise { + const deployed = await this.isDeployed(clusterId); + if (!deployed) { + throw new ServiceUnavailableException( + 'Central logging is not configured. Ask an administrator to deploy Elasticsearch.', + ); + } + + const conn = this.getConnectionInfo(); + const url = `http://${conn.host}:${conn.port}${path}`; + const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64'); + + const response = await fetch(url, { + method: body === undefined ? 'GET' : 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${auth}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + this.logger.warn(`Elasticsearch request failed: ${response.status} ${text}`); + throw new ServiceUnavailableException('Failed to query log storage'); + } + + return response.json(); + } + + private normalizeHit(hit: any): NormalizedLogEntry { + const src = hit._source || {}; + const message = + src.message || + src.log || + src.msg || + (typeof src.error === 'string' ? src.error : src.error?.message) || + ''; + + return { + id: hit._id || '', + timestamp: src['@timestamp'] || src.timestamp || new Date().toISOString(), + level: (src.level || 'info').toString().toLowerCase(), + message: typeof message === 'string' ? message : JSON.stringify(message), + applicationId: src.applicationId, + applicationName: src.applicationName || src.app, + workload: src.workload || 'app', + namespace: src.namespace, + pod: src.kubernetes?.pod_name || src.pod_name, + }; + } + + async searchLogs(userId: string, filters: LogSearchFilters, clusterId?: string): Promise { + const page = filters.page || 1; + const limit = Math.min(filters.limit || 100, 1000); + const from = (page - 1) * limit; + + const body = { + query: { bool: { must: this.buildUserLogMustClauses(userId, filters) } }, + sort: [{ '@timestamp': 'desc' }], + from, + size: limit, + }; + + const index = this.getUserIndexPattern(userId); + const result = await this.esRequest(`/${index}/_search`, body, clusterId); + const hits = result.hits?.hits || []; + + return { + hits: hits.map((h: any) => this.normalizeHit(h)), + total: result.hits?.total?.value ?? result.hits?.total ?? hits.length, + page, + limit, + }; + } + + async searchLogStats( + userId: string, + filters: { applicationId?: string; applicationName?: string; workload?: string; period?: string }, + clusterId?: string, + ): Promise { + const periodMap: Record = { + '1h': 'now-1h', + '6h': 'now-6h', + '24h': 'now-24h', + '7d': 'now-7d', + }; + const period = filters.period || '24h'; + const timeRange = periodMap[period] || 'now-24h'; + + const must = this.buildUserLogMustClauses(userId, { + applicationId: filters.applicationId, + applicationName: filters.applicationName, + workload: filters.workload, + }); + must.push({ range: { '@timestamp': { gte: timeRange } } }); + + const body = { + query: { bool: { must } }, + size: 0, + aggs: { + by_level: { terms: { field: 'level.keyword', size: 10, missing: 'unknown' } }, + by_workload: { terms: { field: 'workload.keyword', size: 10, missing: 'app' } }, + error_count: { filter: { term: { 'level.keyword': 'error' } } }, + warn_count: { filter: { term: { 'level.keyword': 'warn' } } }, + }, + }; + + const index = this.getUserIndexPattern(userId); + const result = await this.esRequest(`/${index}/_search`, body, clusterId); + + const byLevel: Record = {}; + for (const bucket of result.aggregations?.by_level?.buckets || []) { + byLevel[bucket.key] = bucket.doc_count; + } + + const byWorkload: Record = {}; + for (const bucket of result.aggregations?.by_workload?.buckets || []) { + byWorkload[bucket.key] = bucket.doc_count; + } + + const errors = result.aggregations?.error_count?.doc_count || 0; + const warnings = result.aggregations?.warn_count?.doc_count || 0; + const total = Object.values(byLevel).reduce((a, b) => a + b, 0); + + return { total, errors, warnings, byLevel, byWorkload, period }; + } + + async searchRecentErrors( + userId: string, + filters: { applicationId?: string; applicationName?: string; workload?: string; hours?: number; limit?: number }, + clusterId?: string, + ): Promise { + const hours = filters.hours || 24; + const limit = Math.min(filters.limit || 50, 500); + + const must = this.buildUserLogMustClauses(userId, { + applicationId: filters.applicationId, + applicationName: filters.applicationName, + workload: filters.workload, + level: 'error', + }); + must.push({ range: { '@timestamp': { gte: `now-${hours}h` } } }); + + const body = { + query: { bool: { must } }, + sort: [{ '@timestamp': 'desc' }], + size: limit, + }; + + const index = this.getUserIndexPattern(userId); + const result = await this.esRequest(`/${index}/_search`, body, clusterId); + return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h)); + } + + async getLoggingStatus(clusterId?: string): Promise<{ available: boolean; deployed: boolean }> { + const deployed = await this.isDeployed(clusterId); + return { available: deployed, deployed }; + } } diff --git a/backend/src/kubernetes/helm.service.ts b/backend/src/kubernetes/helm.service.ts index 546b225..6ad75ba 100644 --- a/backend/src/kubernetes/helm.service.ts +++ b/backend/src/kubernetes/helm.service.ts @@ -25,25 +25,99 @@ export interface HelmRevision { description: string; } +export const LOGGING_HELM_RELEASE = 'cloudhost-logging'; +export const LOGGING_HELM_NAMESPACE = 'logging'; + +export interface HelmInstallOptions { + wait?: boolean; + timeout?: string; +} + @Injectable() export class HelmService { private readonly logger = new Logger(HelmService.name); - private readonly chartPath: string; + private readonly appChartPath: string; constructor() { - // In production (dist/kubernetes/), __dirname resolves to dist/kubernetes - // so we go up two levels to project root, then into helm/ - // In Docker, the helm/ dir is copied alongside dist/ at /app/helm/ + this.appChartPath = this.resolveChartPath('cloudhost-app'); + } + + private resolveChartPath(chartName: string): string { const candidates = [ - path.resolve(__dirname, '..', '..', 'helm', 'cloudhost-app'), - path.resolve(process.cwd(), 'helm', 'cloudhost-app'), + path.resolve(__dirname, '..', '..', 'helm', chartName), + path.resolve(process.cwd(), 'helm', chartName), ]; - this.chartPath = candidates.find((p) => fs.existsSync(p)) || candidates[0]; + return candidates.find((p) => fs.existsSync(p)) || candidates[0]; } /** - * Install or upgrade a Helm release. - * Equivalent to: helm upgrade --install -n --create-namespace -f + * Install or upgrade a Helm release from a named chart directory. + */ + async installOrUpgradeFromChart( + chartName: string, + releaseName: string, + namespace: string, + values: Record, + kubeconfig: string, + options: HelmInstallOptions = {}, + ): Promise<{ stdout: string; stderr: string }> { + const chartPath = this.resolveChartPath(chartName); + const kubeconfigFile = await this.writeTempKubeconfig(kubeconfig); + const valuesFile = await this.writeTempValues(values); + const wait = options.wait !== false; + const timeout = options.timeout || '5m'; + + try { + const args = [ + 'upgrade', '--install', + releaseName, + chartPath, + '--namespace', namespace, + '--create-namespace', + '--values', valuesFile, + '--history-max', '10', + '--kubeconfig', kubeconfigFile, + ]; + + if (wait) { + args.push('--wait', '--timeout', timeout); + } + + this.logger.log(`Helm install/upgrade: ${releaseName} (${chartName}) in ${namespace}`); + const result = await execFileAsync('helm', args, { timeout: 660_000 }); + this.logger.log(`Helm release ${releaseName} installed/upgraded successfully`); + return result; + } catch (error: any) { + this.logger.error(`Helm install/upgrade failed for ${releaseName}: ${error.stderr || error.message}`); + throw new Error(`Helm install/upgrade failed: ${error.stderr || error.message}`); + } finally { + this.cleanupTempFiles(kubeconfigFile, valuesFile); + } + } + + /** + * Install or upgrade the central logging stack (Elasticsearch + Kibana). + */ + async installLoggingStack( + kubeconfig: string, + values: { elasticPassword: string; fluentbitPassword: string; kibanaSystemPassword: string }, + ): Promise<{ stdout: string; stderr: string }> { + return this.installOrUpgradeFromChart( + 'cloudhost-logging', + LOGGING_HELM_RELEASE, + LOGGING_HELM_NAMESPACE, + { + elasticPassword: values.elasticPassword, + fluentbitPassword: values.fluentbitPassword, + kibanaSystemPassword: values.kibanaSystemPassword, + }, + kubeconfig, + { wait: true, timeout: '10m' }, + ); + } + + /** + * Install or upgrade a user application Helm release. */ async installOrUpgrade( releaseName: string, @@ -58,7 +132,7 @@ export class HelmService { const args = [ 'upgrade', '--install', releaseName, - this.chartPath, + this.appChartPath, '--namespace', namespace, '--create-namespace', '--values', valuesFile, diff --git a/backend/src/kubernetes/kubernetes.module.ts b/backend/src/kubernetes/kubernetes.module.ts index aa574b0..2f67e1b 100644 --- a/backend/src/kubernetes/kubernetes.module.ts +++ b/backend/src/kubernetes/kubernetes.module.ts @@ -1,13 +1,15 @@ import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { KubernetesService } from './kubernetes.service'; import { HelmService } from './helm.service'; import { ElasticsearchService } from './elasticsearch.service'; import { ElasticsearchController } from './elasticsearch.controller'; import { LogsController } from './logs.controller'; import { ClustersModule } from '../clusters/clusters.module'; +import { Application } from '../applications/entities/application.entity'; @Module({ - imports: [forwardRef(() => ClustersModule)], + imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application])], controllers: [ElasticsearchController, LogsController], providers: [KubernetesService, HelmService, ElasticsearchService], exports: [KubernetesService, HelmService, ElasticsearchService], diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 742bd0a..bdd45e1 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -41,6 +41,8 @@ interface ManifestContext { enableElasticsearch: boolean; elasticsearchVersion: string; logPaths: string[]; + ownerId: string; + applicationId: string; } type StorageUsageSlice = { @@ -183,6 +185,8 @@ export class KubernetesService implements OnModuleInit { elasticsearch: { enabled: app.enableElasticsearch || false, logPaths: app.logPaths || [], + ownerId: app.userId, + applicationId: app.id, }, changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`, }; @@ -247,6 +251,8 @@ export class KubernetesService implements OnModuleInit { enableElasticsearch: app.enableElasticsearch || false, elasticsearchVersion: app.elasticsearchVersion || '8.12', logPaths: app.logPaths || [], + ownerId: app.userId, + applicationId: app.id, }; await this.applyIngress(networkingApi, ctx, customDomain); this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`); @@ -305,6 +311,8 @@ export class KubernetesService implements OnModuleInit { enableElasticsearch: app.enableElasticsearch || false, elasticsearchVersion: app.elasticsearchVersion || '8.12', logPaths: app.logPaths || [], + ownerId: app.userId, + applicationId: app.id, }; const manifests: Record = {}; @@ -689,7 +697,15 @@ export class KubernetesService implements OnModuleInit { /** * Build Fluent Bit configuration for log collection */ - private buildFluentBitConfig(appName: string, namespace: string, runtime: string, customLogPaths?: string[]): string { + private buildFluentBitConfig( + appName: string, + namespace: string, + runtime: string, + ownerId: string, + applicationId: string, + workload: string, + customLogPaths?: string[], + ): string { const logPaths = customLogPaths && customLogPaths.length > 0 ? customLogPaths : this.getDefaultLogPaths(runtime); @@ -714,8 +730,12 @@ export class KubernetesService implements OnModuleInit { Name record_modifier Match * Record app ${appName} + Record applicationName ${appName} Record namespace ${namespace} Record runtime ${runtime} + Record ownerId ${ownerId} + Record applicationId ${applicationId} + Record workload ${workload} [FILTER] Name parser @@ -763,7 +783,15 @@ export class KubernetesService implements OnModuleInit { labels: { app: ctx.appName }, }, data: { - 'fluent-bit.conf': this.buildFluentBitConfig(ctx.appName, ctx.namespace, ctx.runtime, customLogPaths), + 'fluent-bit.conf': this.buildFluentBitConfig( + ctx.appName, + ctx.namespace, + ctx.runtime, + ctx.ownerId, + ctx.applicationId, + 'app', + customLogPaths, + ), 'parsers.conf': ` [PARSER] Name json @@ -788,6 +816,127 @@ export class KubernetesService implements OnModuleInit { this.logger.log(`Created Fluent Bit ConfigMap for ${ctx.appName}`); } + private buildWorkloadFluentBitConfig( + ctx: ManifestContext, + workload: 'redis' | 'rabbitmq' | 'database', + resourceName: string, + ): string { + const logGlob = `/var/log/pods/*${resourceName}*/*/*.log`; + return ` +[SERVICE] + Flush 5 + Daemon Off + Log_Level info + Parsers_File /fluent-bit/etc/parsers.conf + +[INPUT] + Name tail + Path ${logGlob} + Tag ${workload}.${resourceName} + Refresh_Interval 5 + Mem_Buf_Limit 5MB + Skip_Long_Lines On + Parser docker + +[FILTER] + Name record_modifier + Match * + Record app ${ctx.appName} + Record applicationName ${ctx.appName} + Record namespace ${ctx.namespace} + Record ownerId ${ctx.ownerId} + Record applicationId ${ctx.applicationId} + Record workload ${workload} + +[OUTPUT] + Name es + Match * + Host \${ES_HOST} + Port \${ES_PORT} + HTTP_User elastic + HTTP_Passwd \${ES_PASSWORD} + Index logs-${ctx.namespace}-${ctx.appName} + Logstash_Format On + Logstash_Prefix logs-${ctx.namespace} + Suppress_Type_Name On + tls Off + Retry_Limit 3 +`; + } + + private async attachWorkloadLogShipper( + coreApi: k8s.CoreV1Api, + ctx: ManifestContext, + workload: 'redis' | 'rabbitmq' | 'database', + resourceName: string, + ): Promise<{ containers: k8s.V1Container[]; volumes: k8s.V1Volume[] }> { + if (!ctx.enableElasticsearch) { + return { containers: [], volumes: [] }; + } + + const configMapName = `${resourceName}-log-shipper-config`; + const configMap = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: configMapName, + namespace: ctx.namespace, + labels: { app: resourceName, 'cloudhost.io/log-shipper': 'true' }, + }, + data: { + 'fluent-bit.conf': this.buildWorkloadFluentBitConfig(ctx, workload, resourceName), + 'parsers.conf': ` +[PARSER] + Name docker + Format json + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L +`, + }, + }; + + try { + await coreApi.replaceNamespacedConfigMap(configMapName, ctx.namespace, configMap); + } catch { + await coreApi.createNamespacedConfigMap(ctx.namespace, configMap); + } + + return { + containers: [ + { + name: 'log-shipper', + image: 'fluent/fluent-bit:2.2', + resources: { + requests: { cpu: '10m', memory: '32Mi' }, + limits: { cpu: '50m', memory: '64Mi' }, + }, + volumeMounts: [ + { name: 'varlogpods', mountPath: '/var/log/pods', readOnly: true }, + { name: 'log-shipper-config', mountPath: '/fluent-bit/etc' }, + ], + env: [ + { name: 'ES_HOST', value: 'elasticsearch.logging.svc.cluster.local' }, + { name: 'ES_PORT', value: '9200' }, + { + name: 'ES_PASSWORD', + valueFrom: { + secretKeyRef: { + name: 'elasticsearch-credentials', + key: 'ELASTIC_PASSWORD', + optional: true, + }, + }, + }, + ], + }, + ], + volumes: [ + { name: 'varlogpods', hostPath: { path: '/var/log/pods', type: 'Directory' } }, + { name: 'log-shipper-config', configMap: { name: configMapName } }, + ], + }; + } + private async applyService(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise { const service: k8s.V1Service = { apiVersion: 'v1', @@ -953,6 +1102,8 @@ export class KubernetesService implements OnModuleInit { throw new Error(`Unsupported database type: ${dbType}`); } + const dbLogShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'database', dbName); + const dbDeployment: k8s.V1Deployment = { apiVersion: 'apps/v1', kind: 'Deployment', @@ -977,9 +1128,11 @@ export class KubernetesService implements OnModuleInit { readinessProbe, livenessProbe, }, + ...dbLogShipper.containers, ], volumes: [ { name: 'db-storage', persistentVolumeClaim: { claimName: dbName } }, + ...dbLogShipper.volumes, ], }, }, @@ -1091,6 +1244,8 @@ export class KubernetesService implements OnModuleInit { await coreApi.createNamespacedSecret(ctx.namespace, redisSecret); } + const logShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'redis', redisName); + // Create Redis Deployment const redisDeployment: k8s.V1Deployment = { apiVersion: 'apps/v1', @@ -1134,12 +1289,14 @@ export class KubernetesService implements OnModuleInit { periodSeconds: 20, }, }, + ...logShipper.containers, ], volumes: [ { name: 'redis-data', persistentVolumeClaim: { claimName: `${redisName}-data` }, }, + ...logShipper.volumes, ], }, }, @@ -1205,6 +1362,8 @@ export class KubernetesService implements OnModuleInit { await coreApi.createNamespacedSecret(ctx.namespace, rabbitSecret); } + const rabbitLogShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'rabbitmq', rabbitName); + // Create RabbitMQ Deployment const rabbitDeployment: k8s.V1Deployment = { apiVersion: 'apps/v1', @@ -1258,12 +1417,14 @@ export class KubernetesService implements OnModuleInit { timeoutSeconds: 10, }, }, + ...rabbitLogShipper.containers, ], volumes: [ { name: 'rabbitmq-data', persistentVolumeClaim: { claimName: `${rabbitName}-data` }, }, + ...rabbitLogShipper.volumes, ], }, }, diff --git a/backend/src/kubernetes/logs.controller.ts b/backend/src/kubernetes/logs.controller.ts index 625541d..c6fa342 100644 --- a/backend/src/kubernetes/logs.controller.ts +++ b/backend/src/kubernetes/logs.controller.ts @@ -15,36 +15,69 @@ import { ApiResponse, } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { ElasticsearchService } from './elasticsearch.service'; +import { Application } from '../applications/entities/application.entity'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { UserRole } from '../common/enums'; interface AuthenticatedRequest { user: { - sub: string; + id: string; email: string; + role: string; }; } @ApiTags('Logs') @ApiBearerAuth() @Controller('logs') -@UseGuards(AuthGuard('jwt')) +@UseGuards(AuthGuard('jwt'), RolesGuard) export class LogsController { - constructor(private readonly esService: ElasticsearchService) {} + constructor( + private readonly esService: ElasticsearchService, + @InjectRepository(Application) + private readonly appsRepo: Repository, + ) {} + + private async resolveAppFilters( + userId: string, + appId?: string, + allowStaff = false, + ): Promise<{ applicationId?: string; applicationName?: string }> { + if (!appId) return {}; + const where = allowStaff ? { id: appId } : { id: appId, userId }; + const app = await this.appsRepo.findOne({ where }); + if (!app) throw new NotFoundException('Application not found'); + return { applicationId: app.id, applicationName: app.name }; + } + + private isStaff(role: string): boolean { + return role === UserRole.ADMIN || role === UserRole.TECHNICAL; + } + + @Get('status') + @ApiOperation({ summary: 'Check if central logging is available' }) + async getStatus() { + return this.esService.getLoggingStatus(); + } @Get() - @ApiOperation({ summary: 'Get logs for authenticated user\'s applications' }) - @ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' }) - @ApiQuery({ name: 'level', required: false, description: 'Filter by log level (error, warn, info, debug)' }) - @ApiQuery({ name: 'from', required: false, description: 'Start time (ISO 8601 format)' }) - @ApiQuery({ name: 'to', required: false, description: 'End time (ISO 8601 format)' }) - @ApiQuery({ name: 'search', required: false, description: 'Full-text search in log messages' }) - @ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' }) - @ApiQuery({ name: 'limit', required: false, description: 'Results per page (default: 100, max: 1000)' }) - @ApiResponse({ status: 200, description: 'User logs' }) - @ApiResponse({ status: 400, description: 'Invalid query parameters' }) + @ApiOperation({ summary: 'Get logs for authenticated user applications' }) + @ApiQuery({ name: 'appId', required: false }) + @ApiQuery({ name: 'workload', required: false, description: 'app | redis | rabbitmq | database' }) + @ApiQuery({ name: 'level', required: false }) + @ApiQuery({ name: 'from', required: false }) + @ApiQuery({ name: 'to', required: false }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) async getUserLogs( @Request() req: AuthenticatedRequest, @Query('appId') appId?: string, + @Query('workload') workload?: string, @Query('level') level?: string, @Query('from') from?: string, @Query('to') to?: string, @@ -52,352 +85,138 @@ export class LogsController { @Query('page') page?: string, @Query('limit') limit?: string, ) { - const userId = req.user.sub; - const pageNum = parseInt(page || '1', 10); - const limitNum = Math.min(parseInt(limit || '100', 10), 1000); - const offset = (pageNum - 1) * limitNum; + const userId = req.user.id; - // Validate log level if (level && !['error', 'warn', 'info', 'debug', 'trace'].includes(level.toLowerCase())) { - throw new BadRequestException('Invalid log level. Use: error, warn, info, debug, or trace'); + throw new BadRequestException('Invalid log level'); } - - // Validate date formats if (from && isNaN(Date.parse(from))) { - throw new BadRequestException('Invalid "from" date format. Use ISO 8601 format.'); + throw new BadRequestException('Invalid "from" date format'); } if (to && isNaN(Date.parse(to))) { - throw new BadRequestException('Invalid "to" date format. Use ISO 8601 format.'); + throw new BadRequestException('Invalid "to" date format'); + } + if (workload && !['app', 'redis', 'rabbitmq', 'database'].includes(workload)) { + throw new BadRequestException('Invalid workload'); } - // Build Elasticsearch query - const baseQuery = this.esService.getUserLogsQuery(userId); - const must: any[] = [baseQuery.query.bool.must]; + const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); - // Add application filter - if (appId) { - must.push({ - term: { 'kubernetes.labels.app': appId }, - }); - } - - // Add level filter - if (level) { - must.push({ - term: { level: level.toLowerCase() }, - }); - } - - // Add time range filter - if (from || to) { - const rangeFilter: any = { range: { '@timestamp': {} } }; - if (from) rangeFilter.range['@timestamp'].gte = from; - if (to) rangeFilter.range['@timestamp'].lte = to; - must.push(rangeFilter); - } - - // Add full-text search - if (search) { - must.push({ - multi_match: { - query: search, - fields: ['message', 'log', 'msg', 'error.message'], - type: 'phrase_prefix', - }, - }); - } - - const query = { - query: { - bool: { - must, - }, - }, - sort: [{ '@timestamp': 'desc' }], - from: offset, - size: limitNum, - }; - - return { - query, - meta: { - page: pageNum, - limit: limitNum, - userId, - filters: { - appId: appId || null, - level: level || null, - from: from || null, - to: to || null, - search: search || null, - }, - }, - usage: { - description: 'Execute this query against Elasticsearch to get logs', - endpoint: 'POST /logs-*/_search', - note: 'Use the Elasticsearch endpoint provided by admin to execute queries', - }, - }; + return this.esService.searchLogs(userId, { + ...appFilters, + workload, + level: level?.toLowerCase(), + from, + to, + search, + page: parseInt(page || '1', 10), + limit: Math.min(parseInt(limit || '100', 10), 1000), + }); } @Get('stream') - @ApiOperation({ summary: 'Get live log stream query for user\'s applications' }) - @ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' }) - @ApiResponse({ status: 200, description: 'Stream query configuration' }) - async getStreamConfig( + @ApiOperation({ summary: 'Recent logs for live tail (last 5 minutes)' }) + @ApiQuery({ name: 'appId', required: false }) + @ApiQuery({ name: 'workload', required: false }) + async getStream( @Request() req: AuthenticatedRequest, @Query('appId') appId?: string, + @Query('workload') workload?: string, ) { - const userId = req.user.sub; + const userId = req.user.id; + const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - // Build query for streaming - const baseQuery = this.esService.getUserLogsQuery(userId); - const must: any[] = [baseQuery.query.bool.must]; - - if (appId) { - must.push({ - term: { 'kubernetes.labels.app': appId }, - }); - } - - // Add time filter for last 5 minutes - must.push({ - range: { - '@timestamp': { - gte: 'now-5m', - }, - }, + return this.esService.searchLogs(userId, { + ...appFilters, + workload, + from: fiveMinAgo, + limit: 100, + page: 1, }); - - const query = { - query: { - bool: { - must, - }, - }, - sort: [{ '@timestamp': 'asc' }], - size: 100, - }; - - return { - query, - meta: { - userId, - appId: appId || 'all', - refreshInterval: '5s', - }, - usage: { - description: 'Poll this query every 5 seconds to get new logs', - note: 'Use search_after for efficient pagination in streaming mode', - }, - }; } @Get('stats') - @ApiOperation({ summary: 'Get log statistics for user\'s applications' }) - @ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' }) - @ApiQuery({ name: 'period', required: false, description: 'Time period: 1h, 6h, 24h, 7d (default: 24h)' }) - @ApiResponse({ status: 200, description: 'Log statistics' }) + @ApiOperation({ summary: 'Log statistics for user applications' }) + @ApiQuery({ name: 'appId', required: false }) + @ApiQuery({ name: 'workload', required: false }) + @ApiQuery({ name: 'period', required: false }) async getLogStats( @Request() req: AuthenticatedRequest, @Query('appId') appId?: string, + @Query('workload') workload?: string, @Query('period') period?: string, ) { - const userId = req.user.sub; - - // Convert period to time range - const periodMap: Record = { - '1h': 'now-1h', - '6h': 'now-6h', - '24h': 'now-24h', - '7d': 'now-7d', - }; - const timeRange = periodMap[period || '24h'] || 'now-24h'; - - // Build aggregation query - const baseQuery = this.esService.getUserLogsQuery(userId); - const must: any[] = [baseQuery.query.bool.must]; - - if (appId) { - must.push({ - term: { 'kubernetes.labels.app': appId }, - }); - } - - must.push({ - range: { - '@timestamp': { - gte: timeRange, - }, - }, + const userId = req.user.id; + const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); + return this.esService.searchLogStats(userId, { + ...appFilters, + workload, + period: period || '24h', }); - - const aggregationQuery = { - query: { - bool: { - must, - }, - }, - size: 0, - aggs: { - by_level: { - terms: { - field: 'level', - size: 10, - }, - }, - by_app: { - terms: { - field: 'kubernetes.labels.app', - size: 50, - }, - }, - over_time: { - date_histogram: { - field: '@timestamp', - fixed_interval: period === '1h' ? '5m' : period === '6h' ? '30m' : '1h', - }, - aggs: { - by_level: { - terms: { - field: 'level', - size: 5, - }, - }, - }, - }, - error_count: { - filter: { - term: { level: 'error' }, - }, - }, - warn_count: { - filter: { - term: { level: 'warn' }, - }, - }, - }, - }; - - return { - query: aggregationQuery, - meta: { - userId, - appId: appId || 'all', - period: period || '24h', - timeRange, - }, - usage: { - description: 'Execute this aggregation query to get log statistics', - endpoint: 'POST /logs-*/_search', - }, - }; } @Get('errors') - @ApiOperation({ summary: 'Get recent errors for user\'s applications' }) - @ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' }) - @ApiQuery({ name: 'hours', required: false, description: 'Hours to look back (default: 24)' }) - @ApiQuery({ name: 'limit', required: false, description: 'Max errors to return (default: 50)' }) - @ApiResponse({ status: 200, description: 'Recent errors' }) + @ApiOperation({ summary: 'Recent error logs' }) + @ApiQuery({ name: 'appId', required: false }) + @ApiQuery({ name: 'workload', required: false }) + @ApiQuery({ name: 'hours', required: false }) + @ApiQuery({ name: 'limit', required: false }) async getRecentErrors( @Request() req: AuthenticatedRequest, @Query('appId') appId?: string, + @Query('workload') workload?: string, @Query('hours') hours?: string, @Query('limit') limit?: string, ) { - const userId = req.user.sub; - const hoursNum = parseInt(hours || '24', 10); - const limitNum = Math.min(parseInt(limit || '50', 10), 500); - - // Build error query - const baseQuery = this.esService.getUserLogsQuery(userId); - const must: any[] = [baseQuery.query.bool.must]; - - if (appId) { - must.push({ - term: { 'kubernetes.labels.app': appId }, - }); - } - - must.push({ - term: { level: 'error' }, + const userId = req.user.id; + const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role)); + const hits = await this.esService.searchRecentErrors(userId, { + ...appFilters, + workload, + hours: parseInt(hours || '24', 10), + limit: Math.min(parseInt(limit || '50', 10), 500), }); - - must.push({ - range: { - '@timestamp': { - gte: `now-${hoursNum}h`, - }, - }, - }); - - const query = { - query: { - bool: { - must, - }, - }, - sort: [{ '@timestamp': 'desc' }], - size: limitNum, - _source: ['@timestamp', 'message', 'log', 'error', 'kubernetes.labels.app', 'kubernetes.pod_name'], - }; - - return { - query, - meta: { - userId, - appId: appId || 'all', - lookbackHours: hoursNum, - limit: limitNum, - }, - usage: { - description: 'Execute this query to get recent errors', - endpoint: 'POST /logs-*/_search', - }, - }; + return { hits, total: hits.length }; } @Get('kibana-url') - @ApiOperation({ summary: 'Get Kibana URL for user\'s application logs' }) - @ApiQuery({ name: 'appId', required: false, description: 'Application ID to filter' }) - @ApiResponse({ status: 200, description: 'Kibana discovery URL' }) + @Roles(UserRole.ADMIN, UserRole.TECHNICAL) + @ApiOperation({ summary: 'Kibana access info (admin/technical only)' }) + @ApiQuery({ name: 'appId', required: false }) async getKibanaUrl( @Request() req: AuthenticatedRequest, @Query('appId') appId?: string, ) { - const userId = req.user.sub; + const userId = req.user.id; const connInfo = this.esService.getConnectionInfo(); + const appFilters = appId + ? await this.resolveAppFilters(userId, appId, true) + : {}; - const filters: Array<{ - meta: { key: string; negate: boolean }; - query: { match_phrase: Record }; - }> = [ - { - meta: { key: 'kubernetes.labels.owner', negate: false }, - query: { match_phrase: { 'kubernetes.labels.owner': userId } }, - }, - ]; - - if (appId) { - filters.push({ - meta: { key: 'kubernetes.labels.app', negate: false }, - query: { match_phrase: { 'kubernetes.labels.app': appId } }, - }); + const filterParts: string[] = []; + if (appFilters.applicationName) { + filterParts.push(`applicationName:${appFilters.applicationName}`); } + filterParts.push(`namespace:user-${userId.split('-')[0]}`); - const rison = encodeURIComponent(JSON.stringify(filters)); + const kibanaHost = connInfo.host.replace('elasticsearch', 'kibana'); + const query = filterParts.length > 0 ? filterParts.join(' AND ') : '*'; return { kibana: { - baseUrl: `http://${connInfo.host.replace('elasticsearch', 'kibana')}:5601`, - discoverUrl: `/app/discover#/?_g=(time:(from:now-24h,to:now))&_a=(filters:!${rison})`, - note: 'Access Kibana through your cluster ingress or port-forward', + internalUrl: `http://${kibanaHost}:5601`, + discoverHint: query, + note: 'Use kubectl port-forward from the admin clusters page. Not exposed to end users.', }, portForward: { command: 'kubectl port-forward svc/kibana 5601:5601 -n logging', localUrl: 'http://localhost:5601', }, + credentials: { + username: 'elastic', + note: 'Password is configured in platform settings / elasticsearch deploy output', + }, }; } } diff --git a/frontend/src/app/dashboard/admin/clusters/page.tsx b/frontend/src/app/dashboard/admin/clusters/page.tsx index f8fc268..e7b6c40 100644 --- a/frontend/src/app/dashboard/admin/clusters/page.tsx +++ b/frontend/src/app/dashboard/admin/clusters/page.tsx @@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; import { toast } from 'react-toastify'; import type { Cluster, ClusterResources } from '@/types'; -import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X } from 'lucide-react'; +import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText, Copy } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; function ResourcePanel({ clusterId }: { clusterId: string }) { @@ -120,6 +120,100 @@ function ResourcePanel({ clusterId }: { clusterId: string }) { ); } +function CentralLoggingPanel() { + const queryClient = useQueryClient(); + const { data: status, isLoading } = useQuery({ + queryKey: ['admin-elasticsearch-status'], + queryFn: () => api.get('/admin/elasticsearch/status').then((r) => r.data), + }); + + const deployMutation = useMutation({ + mutationFn: () => api.post('/admin/elasticsearch/deploy'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); + toast.success('Logging stack deployment started'); + }, + onError: () => toast.error('Failed to deploy logging stack'), + }); + + const undeployMutation = useMutation({ + mutationFn: () => api.delete('/admin/elasticsearch/undeploy'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); + toast.success('Logging stack removed'); + }, + }); + + const kibanaCmd = 'kubectl port-forward svc/kibana 5601:5601 -n logging'; + + return ( +
+
+
+

+ Central logging (Elasticsearch) +

+

+ Required for the unified Logs page. Installed automatically via Helm when a cluster is registered. + End users never get Kibana access — staff use port-forward. +

+
+
+ {!status?.deployed ? ( + + ) : ( + + )} +
+
+ {isLoading ? ( +

Checking status…

+ ) : status?.deployed ? ( +
+

+ Deployed · health: {status.health?.status || 'unknown'} +

+
+

Kibana (staff only)

+
+ {kibanaCmd} + +
+

Then open http://localhost:5601

+
+
+ ) : ( +

+ Not deployed on the default cluster. New clusters install this automatically; use Deploy for existing clusters. +

+ )} +
+ ); +} + export default function AdminClustersPage() { const queryClient = useQueryClient(); const confirm = useConfirm(); @@ -208,6 +302,8 @@ export default function AdminClustersPage() { + + {showForm && (

Register New Cluster

diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 6f001aa..dbe1217 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -6,7 +6,8 @@ import api from '@/lib/api'; import { toast } from 'react-toastify'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; -import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink } from 'lucide-react'; +import NextLink from 'next/link'; +import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; import { BuildProgressModal } from '@/components/build-progress-modal'; @@ -925,6 +926,14 @@ export default function AppDetailPage() { {previewMutation.isPending ? <> : <> Preview} )} + {app.enableElasticsearch && ( + + Logs + + )} )} - -
- {/* Stats Overview */} - {logStats && ( -
-
-
کل لاگ‌ها
-
- {logStats.meta?.period || '-'} -
+ {stats && ( +
+
+

Total ({stats.period})

+

{stats.total}

-
-
خطاها
-
- {recentErrors?.meta?.limit || 0} -
+
+

Errors

+

{stats.errors}

-
-
هشدارها
-
-
+
+

Warnings

+

{stats.warnings}

-
-
اپلیکیشن فعال
-
- {filters.appId - ? applications?.find(a => a.id === filters.appId)?.name || '-' - : `${applications?.length || 0} اپلیکیشن`} -
+
+

Sources

+

+ {Object.entries(stats.byWorkload || {}) + .map(([k, v]) => `${k}: ${v}`) + .join(' · ') || '—'} +

)} - {/* Query Info */} - {logsResult && ( -
-

اطلاعات کوئری

-

{logsResult.usage?.description}

-
-
{JSON.stringify(logsResult.query, null, 2)}
-
-

- 💡 این کوئری را می‌توانید در Elasticsearch یا Kibana اجرا کنید. -

+ {error && ( +
+ Failed to load logs. Ensure logging is enabled on your application and Elasticsearch is running.
)} - {/* Logs Table */} -
-
-

لاگ‌ها

- {isFetching && ( - - - در حال بارگذاری... +
+
+

Log entries

+ {logsResult && ( + + {logsResult.total} total · page {page}/{totalPages} )}
{isLoading ? ( -
- در حال بارگذاری لاگ‌ها... +
+ + Loading logs... +
+ ) : !logsResult?.hits?.length ? ( +
+ No logs found for the selected filters. + {!appId &&

Deploy an app with logging enabled to start collecting logs.

}
) : ( -
- - +
+
+ - - - - + + + + + - - {/* Sample rows - in production, this would come from actual ES query results */} - - - - - - + + {logsResult.hits.map((entry: LogEntry) => ( + + + + + + + + ))}
- زمان - - سطح - - اپلیکیشن - - پیام - TimeLevelAppSourceMessage
- {new Date().toISOString().slice(0, 19).replace('T', ' ')} - - - INFO - - - نمونه اپلیکیشن - - برای مشاهده لاگ‌ها، کوئری بالا را در Elasticsearch اجرا کنید -
+ {new Date(entry.timestamp).toLocaleString()} + + + {entry.level?.toUpperCase()} + + {entry.applicationName || '—'}{entry.workload || 'app'} + {entry.message} +
)} - {/* Pagination info */} - {logsResult?.meta && ( -
- صفحه {logsResult.meta.page} | {logsResult.meta.limit} آیتم در هر صفحه + {logsResult && logsResult.total > logsResult.limit && ( +
+ +
)}
- - {/* Recent Errors */} - {recentErrors && ( -
-
-

آخرین خطاها

-
-
-

- برای مشاهده خطاهای اخیر، کوئری زیر را در Elasticsearch اجرا کنید: -

-
-
{JSON.stringify(recentErrors.query, null, 2)}
-
-
-
- )} - - {/* Kibana Link */} -
-

🔍 مشاهده در Kibana

-

- برای تجربه بهتر در مشاهده و آنالیز لاگ‌ها، از Kibana استفاده کنید. -

-
- kubectl port-forward svc/kibana 5601:5601 -n logging -
-

- سپس به آدرس http://localhost:5601 بروید -

-
); } + +export default function LogsPage() { + return ( + Loading...
}> + + + ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8c6bf28..d7b66e4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -77,6 +77,36 @@ export interface ServiceAccessConnection { url?: string; } +export type LogWorkload = 'app' | 'redis' | 'rabbitmq' | 'database'; + +export interface LogEntry { + id: string; + timestamp: string; + level: string; + message: string; + applicationId?: string; + applicationName?: string; + workload?: LogWorkload | string; + namespace?: string; + pod?: string; +} + +export interface LogSearchResult { + hits: LogEntry[]; + total: number; + page: number; + limit: number; +} + +export interface LogStatsResult { + total: number; + errors: number; + warnings: number; + byLevel: Record; + byWorkload: Record; + period: string; +} + export interface ServiceAccessGrant { id: string; target: ServiceAccessTarget;