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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }}
|
||||||
@@ -138,8 +138,10 @@ spec:
|
|||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 5
|
failureThreshold: 5
|
||||||
|
{{- include "cloudhost-app.logShipperContainers" (dict "root" . "workloadName" $dbName "workloadType" "database") | nindent 8 }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: db-storage
|
- name: db-storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ $dbName }}
|
claimName: {{ $dbName }}
|
||||||
|
{{- include "cloudhost-app.logShipperVolumes" (dict "root" . "workloadName" $dbName) | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ data:
|
|||||||
Name record_modifier
|
Name record_modifier
|
||||||
Match *
|
Match *
|
||||||
Record app {{ $name }}
|
Record app {{ $name }}
|
||||||
|
Record applicationName {{ $name }}
|
||||||
Record namespace {{ $ns }}
|
Record namespace {{ $ns }}
|
||||||
Record runtime {{ .Values.app.runtime }}
|
Record runtime {{ .Values.app.runtime }}
|
||||||
|
Record ownerId {{ .Values.elasticsearch.ownerId }}
|
||||||
|
Record applicationId {{ .Values.elasticsearch.applicationId }}
|
||||||
|
Record workload app
|
||||||
|
|
||||||
[FILTER]
|
[FILTER]
|
||||||
Name parser
|
Name parser
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -94,10 +94,12 @@ spec:
|
|||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
timeoutSeconds: 10
|
timeoutSeconds: 10
|
||||||
|
{{- include "cloudhost-app.logShipperContainers" (dict "root" . "workloadName" $rabbitName "workloadType" "rabbitmq") | nindent 8 }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: rabbitmq-data
|
- name: rabbitmq-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ $rabbitName }}-data
|
claimName: {{ $rabbitName }}-data
|
||||||
|
{{- include "cloudhost-app.logShipperVolumes" (dict "root" . "workloadName" $rabbitName) | nindent 8 }}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ spec:
|
|||||||
command: ["redis-cli", "ping"]
|
command: ["redis-cli", "ping"]
|
||||||
initialDelaySeconds: 15
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
|
{{- include "cloudhost-app.logShipperContainers" (dict "root" . "workloadName" $redisName "workloadType" "redis") | nindent 8 }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: redis-data
|
- name: redis-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ $redisName }}-data
|
claimName: {{ $redisName }}-data
|
||||||
|
{{- include "cloudhost-app.logShipperVolumes" (dict "root" . "workloadName" $redisName) | nindent 8 }}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ rabbitmq:
|
|||||||
elasticsearch:
|
elasticsearch:
|
||||||
enabled: false
|
enabled: false
|
||||||
logPaths: []
|
logPaths: []
|
||||||
|
ownerId: ""
|
||||||
|
applicationId: ""
|
||||||
|
|
||||||
# ── Change metadata ─────────────────────────────────────
|
# ── Change metadata ─────────────────────────────────────
|
||||||
changeCause: ""
|
changeCause: ""
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -104,7 +104,7 @@ export class ApplicationsService {
|
|||||||
subdomain,
|
subdomain,
|
||||||
customDomain: customDomain || undefined,
|
customDomain: customDomain || undefined,
|
||||||
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
|
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
|
||||||
envVars: dto.envVars,
|
envVars: dto.envVars ?? {},
|
||||||
},
|
},
|
||||||
platformDomain,
|
platformDomain,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ClustersService } from './clusters.service';
|
import { ClustersService } from './clusters.service';
|
||||||
import { ClustersController } from './clusters.controller';
|
import { ClustersController } from './clusters.controller';
|
||||||
import { Cluster } from './entities/cluster.entity';
|
import { Cluster } from './entities/cluster.entity';
|
||||||
import { ClusterPool } from './entities/cluster-pool.entity';
|
import { ClusterPool } from './entities/cluster-pool.entity';
|
||||||
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Cluster, ClusterPool])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Cluster, ClusterPool]),
|
||||||
|
forwardRef(() => KubernetesModule),
|
||||||
|
],
|
||||||
controllers: [ClustersController],
|
controllers: [ClustersController],
|
||||||
providers: [ClustersService],
|
providers: [ClustersService],
|
||||||
exports: [ClustersService],
|
exports: [ClustersService],
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Repository, DataSource, In } from 'typeorm';
|
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 { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||||
import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto';
|
import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto';
|
||||||
import { ClusterStatus } from '../common/enums';
|
import { ClusterStatus } from '../common/enums';
|
||||||
|
import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClustersService {
|
export class ClustersService {
|
||||||
@@ -22,6 +23,8 @@ export class ClustersService {
|
|||||||
private poolsRepository: Repository<ClusterPool>,
|
private poolsRepository: Repository<ClusterPool>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private configService: ConfigService,
|
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}`);
|
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;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export default () => ({
|
|||||||
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
|
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: {
|
platform: {
|
||||||
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
||||||
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||||
|
|||||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import * as k8s from '@kubernetes/client-node';
|
import * as k8s from '@kubernetes/client-node';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
|
||||||
|
|
||||||
interface ElasticsearchCredentials {
|
interface ElasticsearchCredentials {
|
||||||
username: string;
|
username: string;
|
||||||
password: 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;
|
timestamp: string;
|
||||||
level: string;
|
level: string;
|
||||||
message: string;
|
message: string;
|
||||||
app: string;
|
applicationId?: string;
|
||||||
namespace: string;
|
applicationName?: string;
|
||||||
[key: string]: any;
|
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<string, number>;
|
||||||
|
byWorkload: Record<string, number>;
|
||||||
|
period: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,13 +65,17 @@ export class ElasticsearchService {
|
|||||||
// Default credentials - should be overridden via env in production
|
// Default credentials - should be overridden via env in production
|
||||||
private readonly ELASTIC_PASSWORD: string;
|
private readonly ELASTIC_PASSWORD: string;
|
||||||
private readonly FLUENTBIT_PASSWORD: string;
|
private readonly FLUENTBIT_PASSWORD: string;
|
||||||
|
private readonly KIBANA_SYSTEM_PASSWORD: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(forwardRef(() => ClustersService))
|
||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private helmService: HelmService,
|
||||||
) {
|
) {
|
||||||
this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure';
|
this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure';
|
||||||
this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer';
|
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) {
|
private async getK8sClients(clusterId?: string) {
|
||||||
@@ -133,25 +169,20 @@ export class ElasticsearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy central Elasticsearch + Kibana stack
|
* Deploy central Elasticsearch + Kibana stack via Helm.
|
||||||
* This should be called once per cluster by admin
|
|
||||||
*/
|
*/
|
||||||
async deploy(clusterId?: string): Promise<{ esPassword: string; kibanaUrl: string }> {
|
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.helmService.installLoggingStack(cluster.kubeconfig, {
|
||||||
await this.ensureNamespace(coreApi);
|
elasticPassword: this.ELASTIC_PASSWORD,
|
||||||
|
fluentbitPassword: this.FLUENTBIT_PASSWORD,
|
||||||
|
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Create credentials secret
|
this.logger.log(
|
||||||
await this.createCredentialsSecret(coreApi);
|
`Central logging stack deployed via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`,
|
||||||
|
);
|
||||||
// 3. Deploy Elasticsearch
|
|
||||||
await this.deployElasticsearch(coreApi, appsApi);
|
|
||||||
|
|
||||||
// 4. Deploy Kibana
|
|
||||||
await this.deployKibana(coreApi, appsApi);
|
|
||||||
|
|
||||||
this.logger.log('Central Elasticsearch stack deployed successfully');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
esPassword: this.ELASTIC_PASSWORD,
|
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<void> {
|
async undeploy(clusterId?: string): Promise<void> {
|
||||||
const { coreApi, appsApi } = await this.getK8sClients(clusterId);
|
const { cluster } = await this.getK8sClients(clusterId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete Kibana
|
await this.helmService.uninstall(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
|
||||||
await appsApi.deleteNamespacedDeployment(this.KIBANA_NAME, this.ES_NAMESPACE);
|
this.logger.log('Elasticsearch stack undeployed via Helm (PVC preserved)');
|
||||||
await coreApi.deleteNamespacedService(this.KIBANA_NAME, this.ES_NAMESPACE);
|
|
||||||
this.logger.log('Kibana deleted');
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.response?.statusCode !== 404) {
|
const msg = e?.message || String(e);
|
||||||
this.logger.warn(`Failed to delete Kibana: ${e.message}`);
|
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
// 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<void> {
|
|
||||||
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
|
* Build must clauses for user log isolation (new + legacy fields).
|
||||||
* Users can only see logs from their own applications
|
|
||||||
*/
|
*/
|
||||||
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 {
|
return {
|
||||||
query: {
|
query: {
|
||||||
bool: {
|
bool: {
|
||||||
must: {
|
must: this.buildUserLogMustClauses(userId),
|
||||||
term: {
|
|
||||||
'kubernetes.labels.owner': userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserIndexPattern(userId: string): string {
|
||||||
|
return `logs-user-${userId.split('-')[0]}-*`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> {
|
||||||
|
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<LogSearchResult> {
|
||||||
|
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<LogStatsResult> {
|
||||||
|
const periodMap: Record<string, string> = {
|
||||||
|
'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<string, number> = {};
|
||||||
|
for (const bucket of result.aggregations?.by_level?.buckets || []) {
|
||||||
|
byLevel[bucket.key] = bucket.doc_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byWorkload: Record<string, number> = {};
|
||||||
|
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<NormalizedLogEntry[]> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,25 +25,99 @@ export interface HelmRevision {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LOGGING_HELM_RELEASE = 'cloudhost-logging';
|
||||||
|
export const LOGGING_HELM_NAMESPACE = 'logging';
|
||||||
|
|
||||||
|
export interface HelmInstallOptions {
|
||||||
|
wait?: boolean;
|
||||||
|
timeout?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HelmService {
|
export class HelmService {
|
||||||
private readonly logger = new Logger(HelmService.name);
|
private readonly logger = new Logger(HelmService.name);
|
||||||
private readonly chartPath: string;
|
private readonly appChartPath: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// In production (dist/kubernetes/), __dirname resolves to dist/kubernetes
|
this.appChartPath = this.resolveChartPath('cloudhost-app');
|
||||||
// so we go up two levels to project root, then into helm/
|
}
|
||||||
// In Docker, the helm/ dir is copied alongside dist/ at /app/helm/
|
|
||||||
|
private resolveChartPath(chartName: string): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.resolve(__dirname, '..', '..', 'helm', 'cloudhost-app'),
|
path.resolve(__dirname, '..', '..', 'helm', chartName),
|
||||||
path.resolve(process.cwd(), 'helm', 'cloudhost-app'),
|
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.
|
* Install or upgrade a Helm release from a named chart directory.
|
||||||
* Equivalent to: helm upgrade --install <release> <chart> -n <ns> --create-namespace -f <values>
|
*/
|
||||||
|
async installOrUpgradeFromChart(
|
||||||
|
chartName: string,
|
||||||
|
releaseName: string,
|
||||||
|
namespace: string,
|
||||||
|
values: Record<string, any>,
|
||||||
|
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(
|
async installOrUpgrade(
|
||||||
releaseName: string,
|
releaseName: string,
|
||||||
@@ -58,7 +132,7 @@ export class HelmService {
|
|||||||
const args = [
|
const args = [
|
||||||
'upgrade', '--install',
|
'upgrade', '--install',
|
||||||
releaseName,
|
releaseName,
|
||||||
this.chartPath,
|
this.appChartPath,
|
||||||
'--namespace', namespace,
|
'--namespace', namespace,
|
||||||
'--create-namespace',
|
'--create-namespace',
|
||||||
'--values', valuesFile,
|
'--values', valuesFile,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { KubernetesService } from './kubernetes.service';
|
import { KubernetesService } from './kubernetes.service';
|
||||||
import { HelmService } from './helm.service';
|
import { HelmService } from './helm.service';
|
||||||
import { ElasticsearchService } from './elasticsearch.service';
|
import { ElasticsearchService } from './elasticsearch.service';
|
||||||
import { ElasticsearchController } from './elasticsearch.controller';
|
import { ElasticsearchController } from './elasticsearch.controller';
|
||||||
import { LogsController } from './logs.controller';
|
import { LogsController } from './logs.controller';
|
||||||
import { ClustersModule } from '../clusters/clusters.module';
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => ClustersModule)],
|
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application])],
|
||||||
controllers: [ElasticsearchController, LogsController],
|
controllers: [ElasticsearchController, LogsController],
|
||||||
providers: [KubernetesService, HelmService, ElasticsearchService],
|
providers: [KubernetesService, HelmService, ElasticsearchService],
|
||||||
exports: [KubernetesService, HelmService, ElasticsearchService],
|
exports: [KubernetesService, HelmService, ElasticsearchService],
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ interface ManifestContext {
|
|||||||
enableElasticsearch: boolean;
|
enableElasticsearch: boolean;
|
||||||
elasticsearchVersion: string;
|
elasticsearchVersion: string;
|
||||||
logPaths: string[];
|
logPaths: string[];
|
||||||
|
ownerId: string;
|
||||||
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageUsageSlice = {
|
type StorageUsageSlice = {
|
||||||
@@ -183,6 +185,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
elasticsearch: {
|
elasticsearch: {
|
||||||
enabled: app.enableElasticsearch || false,
|
enabled: app.enableElasticsearch || false,
|
||||||
logPaths: app.logPaths || [],
|
logPaths: app.logPaths || [],
|
||||||
|
ownerId: app.userId,
|
||||||
|
applicationId: app.id,
|
||||||
},
|
},
|
||||||
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
|
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
|
||||||
};
|
};
|
||||||
@@ -247,6 +251,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
enableElasticsearch: app.enableElasticsearch || false,
|
enableElasticsearch: app.enableElasticsearch || false,
|
||||||
elasticsearchVersion: app.elasticsearchVersion || '8.12',
|
elasticsearchVersion: app.elasticsearchVersion || '8.12',
|
||||||
logPaths: app.logPaths || [],
|
logPaths: app.logPaths || [],
|
||||||
|
ownerId: app.userId,
|
||||||
|
applicationId: app.id,
|
||||||
};
|
};
|
||||||
await this.applyIngress(networkingApi, ctx, customDomain);
|
await this.applyIngress(networkingApi, ctx, customDomain);
|
||||||
this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`);
|
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,
|
enableElasticsearch: app.enableElasticsearch || false,
|
||||||
elasticsearchVersion: app.elasticsearchVersion || '8.12',
|
elasticsearchVersion: app.elasticsearchVersion || '8.12',
|
||||||
logPaths: app.logPaths || [],
|
logPaths: app.logPaths || [],
|
||||||
|
ownerId: app.userId,
|
||||||
|
applicationId: app.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const manifests: Record<string, any> = {};
|
const manifests: Record<string, any> = {};
|
||||||
@@ -689,7 +697,15 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
/**
|
/**
|
||||||
* Build Fluent Bit configuration for log collection
|
* 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
|
const logPaths = customLogPaths && customLogPaths.length > 0
|
||||||
? customLogPaths
|
? customLogPaths
|
||||||
: this.getDefaultLogPaths(runtime);
|
: this.getDefaultLogPaths(runtime);
|
||||||
@@ -714,8 +730,12 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
Name record_modifier
|
Name record_modifier
|
||||||
Match *
|
Match *
|
||||||
Record app ${appName}
|
Record app ${appName}
|
||||||
|
Record applicationName ${appName}
|
||||||
Record namespace ${namespace}
|
Record namespace ${namespace}
|
||||||
Record runtime ${runtime}
|
Record runtime ${runtime}
|
||||||
|
Record ownerId ${ownerId}
|
||||||
|
Record applicationId ${applicationId}
|
||||||
|
Record workload ${workload}
|
||||||
|
|
||||||
[FILTER]
|
[FILTER]
|
||||||
Name parser
|
Name parser
|
||||||
@@ -763,7 +783,15 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
labels: { app: ctx.appName },
|
labels: { app: ctx.appName },
|
||||||
},
|
},
|
||||||
data: {
|
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': `
|
'parsers.conf': `
|
||||||
[PARSER]
|
[PARSER]
|
||||||
Name json
|
Name json
|
||||||
@@ -788,6 +816,127 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
this.logger.log(`Created Fluent Bit ConfigMap for ${ctx.appName}`);
|
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<any> {
|
private async applyService(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
|
||||||
const service: k8s.V1Service = {
|
const service: k8s.V1Service = {
|
||||||
apiVersion: 'v1',
|
apiVersion: 'v1',
|
||||||
@@ -953,6 +1102,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
throw new Error(`Unsupported database type: ${dbType}`);
|
throw new Error(`Unsupported database type: ${dbType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbLogShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'database', dbName);
|
||||||
|
|
||||||
const dbDeployment: k8s.V1Deployment = {
|
const dbDeployment: k8s.V1Deployment = {
|
||||||
apiVersion: 'apps/v1',
|
apiVersion: 'apps/v1',
|
||||||
kind: 'Deployment',
|
kind: 'Deployment',
|
||||||
@@ -977,9 +1128,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
readinessProbe,
|
readinessProbe,
|
||||||
livenessProbe,
|
livenessProbe,
|
||||||
},
|
},
|
||||||
|
...dbLogShipper.containers,
|
||||||
],
|
],
|
||||||
volumes: [
|
volumes: [
|
||||||
{ name: 'db-storage', persistentVolumeClaim: { claimName: dbName } },
|
{ name: 'db-storage', persistentVolumeClaim: { claimName: dbName } },
|
||||||
|
...dbLogShipper.volumes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1091,6 +1244,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
await coreApi.createNamespacedSecret(ctx.namespace, redisSecret);
|
await coreApi.createNamespacedSecret(ctx.namespace, redisSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'redis', redisName);
|
||||||
|
|
||||||
// Create Redis Deployment
|
// Create Redis Deployment
|
||||||
const redisDeployment: k8s.V1Deployment = {
|
const redisDeployment: k8s.V1Deployment = {
|
||||||
apiVersion: 'apps/v1',
|
apiVersion: 'apps/v1',
|
||||||
@@ -1134,12 +1289,14 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
periodSeconds: 20,
|
periodSeconds: 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...logShipper.containers,
|
||||||
],
|
],
|
||||||
volumes: [
|
volumes: [
|
||||||
{
|
{
|
||||||
name: 'redis-data',
|
name: 'redis-data',
|
||||||
persistentVolumeClaim: { claimName: `${redisName}-data` },
|
persistentVolumeClaim: { claimName: `${redisName}-data` },
|
||||||
},
|
},
|
||||||
|
...logShipper.volumes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1205,6 +1362,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
await coreApi.createNamespacedSecret(ctx.namespace, rabbitSecret);
|
await coreApi.createNamespacedSecret(ctx.namespace, rabbitSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rabbitLogShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'rabbitmq', rabbitName);
|
||||||
|
|
||||||
// Create RabbitMQ Deployment
|
// Create RabbitMQ Deployment
|
||||||
const rabbitDeployment: k8s.V1Deployment = {
|
const rabbitDeployment: k8s.V1Deployment = {
|
||||||
apiVersion: 'apps/v1',
|
apiVersion: 'apps/v1',
|
||||||
@@ -1258,12 +1417,14 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
timeoutSeconds: 10,
|
timeoutSeconds: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...rabbitLogShipper.containers,
|
||||||
],
|
],
|
||||||
volumes: [
|
volumes: [
|
||||||
{
|
{
|
||||||
name: 'rabbitmq-data',
|
name: 'rabbitmq-data',
|
||||||
persistentVolumeClaim: { claimName: `${rabbitName}-data` },
|
persistentVolumeClaim: { claimName: `${rabbitName}-data` },
|
||||||
},
|
},
|
||||||
|
...rabbitLogShipper.volumes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,36 +15,69 @@ import {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { ElasticsearchService } from './elasticsearch.service';
|
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 {
|
interface AuthenticatedRequest {
|
||||||
user: {
|
user: {
|
||||||
sub: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Logs')
|
@ApiTags('Logs')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('logs')
|
@Controller('logs')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
export class LogsController {
|
export class LogsController {
|
||||||
constructor(private readonly esService: ElasticsearchService) {}
|
constructor(
|
||||||
|
private readonly esService: ElasticsearchService,
|
||||||
|
@InjectRepository(Application)
|
||||||
|
private readonly appsRepo: Repository<Application>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get logs for authenticated user\'s applications' })
|
@ApiOperation({ summary: 'Get logs for authenticated user applications' })
|
||||||
@ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' })
|
@ApiQuery({ name: 'appId', required: false })
|
||||||
@ApiQuery({ name: 'level', required: false, description: 'Filter by log level (error, warn, info, debug)' })
|
@ApiQuery({ name: 'workload', required: false, description: 'app | redis | rabbitmq | database' })
|
||||||
@ApiQuery({ name: 'from', required: false, description: 'Start time (ISO 8601 format)' })
|
@ApiQuery({ name: 'level', required: false })
|
||||||
@ApiQuery({ name: 'to', required: false, description: 'End time (ISO 8601 format)' })
|
@ApiQuery({ name: 'from', required: false })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'Full-text search in log messages' })
|
@ApiQuery({ name: 'to', required: false })
|
||||||
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
|
@ApiQuery({ name: 'search', required: false })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'Results per page (default: 100, max: 1000)' })
|
@ApiQuery({ name: 'page', required: false })
|
||||||
@ApiResponse({ status: 200, description: 'User logs' })
|
@ApiQuery({ name: 'limit', required: false })
|
||||||
@ApiResponse({ status: 400, description: 'Invalid query parameters' })
|
|
||||||
async getUserLogs(
|
async getUserLogs(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query('appId') appId?: string,
|
@Query('appId') appId?: string,
|
||||||
|
@Query('workload') workload?: string,
|
||||||
@Query('level') level?: string,
|
@Query('level') level?: string,
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -52,352 +85,138 @@ export class LogsController {
|
|||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
) {
|
) {
|
||||||
const userId = req.user.sub;
|
const userId = req.user.id;
|
||||||
const pageNum = parseInt(page || '1', 10);
|
|
||||||
const limitNum = Math.min(parseInt(limit || '100', 10), 1000);
|
|
||||||
const offset = (pageNum - 1) * limitNum;
|
|
||||||
|
|
||||||
// Validate log level
|
|
||||||
if (level && !['error', 'warn', 'info', 'debug', 'trace'].includes(level.toLowerCase())) {
|
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))) {
|
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))) {
|
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 appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
|
||||||
const baseQuery = this.esService.getUserLogsQuery(userId);
|
|
||||||
const must: any[] = [baseQuery.query.bool.must];
|
|
||||||
|
|
||||||
// Add application filter
|
return this.esService.searchLogs(userId, {
|
||||||
if (appId) {
|
...appFilters,
|
||||||
must.push({
|
workload,
|
||||||
term: { 'kubernetes.labels.app': appId },
|
level: level?.toLowerCase(),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
search,
|
||||||
|
page: parseInt(page || '1', 10),
|
||||||
|
limit: Math.min(parseInt(limit || '100', 10), 1000),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('stream')
|
@Get('stream')
|
||||||
@ApiOperation({ summary: 'Get live log stream query for user\'s applications' })
|
@ApiOperation({ summary: 'Recent logs for live tail (last 5 minutes)' })
|
||||||
@ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' })
|
@ApiQuery({ name: 'appId', required: false })
|
||||||
@ApiResponse({ status: 200, description: 'Stream query configuration' })
|
@ApiQuery({ name: 'workload', required: false })
|
||||||
async getStreamConfig(
|
async getStream(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query('appId') appId?: string,
|
@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
|
return this.esService.searchLogs(userId, {
|
||||||
const baseQuery = this.esService.getUserLogsQuery(userId);
|
...appFilters,
|
||||||
const must: any[] = [baseQuery.query.bool.must];
|
workload,
|
||||||
|
from: fiveMinAgo,
|
||||||
if (appId) {
|
limit: 100,
|
||||||
must.push({
|
page: 1,
|
||||||
term: { 'kubernetes.labels.app': appId },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add time filter for last 5 minutes
|
|
||||||
must.push({
|
|
||||||
range: {
|
|
||||||
'@timestamp': {
|
|
||||||
gte: 'now-5m',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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')
|
@Get('stats')
|
||||||
@ApiOperation({ summary: 'Get log statistics for user\'s applications' })
|
@ApiOperation({ summary: 'Log statistics for user applications' })
|
||||||
@ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' })
|
@ApiQuery({ name: 'appId', required: false })
|
||||||
@ApiQuery({ name: 'period', required: false, description: 'Time period: 1h, 6h, 24h, 7d (default: 24h)' })
|
@ApiQuery({ name: 'workload', required: false })
|
||||||
@ApiResponse({ status: 200, description: 'Log statistics' })
|
@ApiQuery({ name: 'period', required: false })
|
||||||
async getLogStats(
|
async getLogStats(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query('appId') appId?: string,
|
@Query('appId') appId?: string,
|
||||||
|
@Query('workload') workload?: string,
|
||||||
@Query('period') period?: string,
|
@Query('period') period?: string,
|
||||||
) {
|
) {
|
||||||
const userId = req.user.sub;
|
const userId = req.user.id;
|
||||||
|
const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
|
||||||
// Convert period to time range
|
return this.esService.searchLogStats(userId, {
|
||||||
const periodMap: Record<string, string> = {
|
...appFilters,
|
||||||
'1h': 'now-1h',
|
workload,
|
||||||
'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 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',
|
period: period || '24h',
|
||||||
timeRange,
|
});
|
||||||
},
|
|
||||||
usage: {
|
|
||||||
description: 'Execute this aggregation query to get log statistics',
|
|
||||||
endpoint: 'POST /logs-*/_search',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('errors')
|
@Get('errors')
|
||||||
@ApiOperation({ summary: 'Get recent errors for user\'s applications' })
|
@ApiOperation({ summary: 'Recent error logs' })
|
||||||
@ApiQuery({ name: 'appId', required: false, description: 'Filter by application ID' })
|
@ApiQuery({ name: 'appId', required: false })
|
||||||
@ApiQuery({ name: 'hours', required: false, description: 'Hours to look back (default: 24)' })
|
@ApiQuery({ name: 'workload', required: false })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'Max errors to return (default: 50)' })
|
@ApiQuery({ name: 'hours', required: false })
|
||||||
@ApiResponse({ status: 200, description: 'Recent errors' })
|
@ApiQuery({ name: 'limit', required: false })
|
||||||
async getRecentErrors(
|
async getRecentErrors(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query('appId') appId?: string,
|
@Query('appId') appId?: string,
|
||||||
|
@Query('workload') workload?: string,
|
||||||
@Query('hours') hours?: string,
|
@Query('hours') hours?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
) {
|
) {
|
||||||
const userId = req.user.sub;
|
const userId = req.user.id;
|
||||||
const hoursNum = parseInt(hours || '24', 10);
|
const appFilters = await this.resolveAppFilters(userId, appId, this.isStaff(req.user.role));
|
||||||
const limitNum = Math.min(parseInt(limit || '50', 10), 500);
|
const hits = await this.esService.searchRecentErrors(userId, {
|
||||||
|
...appFilters,
|
||||||
// Build error query
|
workload,
|
||||||
const baseQuery = this.esService.getUserLogsQuery(userId);
|
hours: parseInt(hours || '24', 10),
|
||||||
const must: any[] = [baseQuery.query.bool.must];
|
limit: Math.min(parseInt(limit || '50', 10), 500),
|
||||||
|
|
||||||
if (appId) {
|
|
||||||
must.push({
|
|
||||||
term: { 'kubernetes.labels.app': appId },
|
|
||||||
});
|
});
|
||||||
}
|
return { hits, total: hits.length };
|
||||||
|
|
||||||
must.push({
|
|
||||||
term: { level: 'error' },
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('kibana-url')
|
@Get('kibana-url')
|
||||||
@ApiOperation({ summary: 'Get Kibana URL for user\'s application logs' })
|
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||||
@ApiQuery({ name: 'appId', required: false, description: 'Application ID to filter' })
|
@ApiOperation({ summary: 'Kibana access info (admin/technical only)' })
|
||||||
@ApiResponse({ status: 200, description: 'Kibana discovery URL' })
|
@ApiQuery({ name: 'appId', required: false })
|
||||||
async getKibanaUrl(
|
async getKibanaUrl(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query('appId') appId?: string,
|
@Query('appId') appId?: string,
|
||||||
) {
|
) {
|
||||||
const userId = req.user.sub;
|
const userId = req.user.id;
|
||||||
const connInfo = this.esService.getConnectionInfo();
|
const connInfo = this.esService.getConnectionInfo();
|
||||||
|
const appFilters = appId
|
||||||
|
? await this.resolveAppFilters(userId, appId, true)
|
||||||
|
: {};
|
||||||
|
|
||||||
const filters: Array<{
|
const filterParts: string[] = [];
|
||||||
meta: { key: string; negate: boolean };
|
if (appFilters.applicationName) {
|
||||||
query: { match_phrase: Record<string, string> };
|
filterParts.push(`applicationName:${appFilters.applicationName}`);
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
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 } },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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 {
|
return {
|
||||||
kibana: {
|
kibana: {
|
||||||
baseUrl: `http://${connInfo.host.replace('elasticsearch', 'kibana')}:5601`,
|
internalUrl: `http://${kibanaHost}:5601`,
|
||||||
discoverUrl: `/app/discover#/?_g=(time:(from:now-24h,to:now))&_a=(filters:!${rison})`,
|
discoverHint: query,
|
||||||
note: 'Access Kibana through your cluster ingress or port-forward',
|
note: 'Use kubectl port-forward from the admin clusters page. Not exposed to end users.',
|
||||||
},
|
},
|
||||||
portForward: {
|
portForward: {
|
||||||
command: 'kubectl port-forward svc/kibana 5601:5601 -n logging',
|
command: 'kubectl port-forward svc/kibana 5601:5601 -n logging',
|
||||||
localUrl: 'http://localhost:5601',
|
localUrl: 'http://localhost:5601',
|
||||||
},
|
},
|
||||||
|
credentials: {
|
||||||
|
username: 'elastic',
|
||||||
|
note: 'Password is configured in platform settings / elasticsearch deploy output',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { Cluster, ClusterResources } from '@/types';
|
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';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
|
|
||||||
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
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 (
|
||||||
|
<div className="card p-4 border border-indigo-100 bg-indigo-50/30">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<ScrollText className="w-5 h-5 text-indigo-600" /> Central logging (Elasticsearch)
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!status?.deployed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deployMutation.mutate()}
|
||||||
|
disabled={deployMutation.isPending}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{deployMutation.isPending ? 'Deploying…' : 'Deploy stack'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => undeployMutation.mutate()}
|
||||||
|
disabled={undeployMutation.isPending}
|
||||||
|
className="btn-secondary text-sm text-red-600"
|
||||||
|
>
|
||||||
|
Remove stack
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-gray-500">Checking status…</p>
|
||||||
|
) : status?.deployed ? (
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<p className="text-green-700 font-medium flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-4 h-4" /> Deployed · health: {status.health?.status || 'unknown'}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-3 border border-gray-200">
|
||||||
|
<p className="text-xs font-medium text-gray-600 mb-1">Kibana (staff only)</p>
|
||||||
|
<div className="flex items-center gap-2 font-mono text-xs">
|
||||||
|
<code className="flex-1 break-all">{kibanaCmd}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(kibanaCmd);
|
||||||
|
toast.success('Copied');
|
||||||
|
}}
|
||||||
|
className="p-1 text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Then open http://localhost:5601</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-amber-700">
|
||||||
|
Not deployed on the default cluster. New clusters install this automatically; use Deploy for existing clusters.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminClustersPage() {
|
export default function AdminClustersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -208,6 +302,8 @@ export default function AdminClustersPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CentralLoggingPanel />
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="card space-y-4 animate-slide-up">
|
<div className="card space-y-4 animate-slide-up">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import api from '@/lib/api';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
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 { useConfirm } from '@/components/confirm-modal';
|
||||||
import { BuildProgressModal } from '@/components/build-progress-modal';
|
import { BuildProgressModal } from '@/components/build-progress-modal';
|
||||||
|
|
||||||
@@ -925,6 +926,14 @@ export default function AppDetailPage() {
|
|||||||
{previewMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Globe className="w-3 h-3 inline" /> Preview</>}
|
{previewMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Globe className="w-3 h-3 inline" /> Preview</>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{app.enableElasticsearch && (
|
||||||
|
<NextLink
|
||||||
|
href={`/dashboard/logs?appId=${appId}`}
|
||||||
|
className="text-sm px-4 py-2 rounded-xl font-medium bg-slate-50 text-slate-700 hover:bg-slate-100 border border-slate-200 transition-all active:scale-[0.98] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ScrollText className="w-3 h-3" /> Logs
|
||||||
|
</NextLink>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
Wallet,
|
Wallet,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
ScrollText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type NavItem = { href: string; label: string; icon: ReactNode };
|
type NavItem = { href: string; label: string; icon: ReactNode };
|
||||||
@@ -31,6 +32,7 @@ type NavItem = { href: string; label: string; icon: ReactNode };
|
|||||||
const userNavItems: NavItem[] = [
|
const userNavItems: NavItem[] = [
|
||||||
{ href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
{ href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
|
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
|
||||||
|
{ href: '/dashboard/logs', label: 'Logs', icon: <ScrollText className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
|
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
|
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
|
||||||
|
|||||||
@@ -1,401 +1,389 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Loader2, RefreshCw, Search, Filter, Clock, AlertCircle, AlertTriangle, Info, Bug } from 'lucide-react';
|
import api from '@/lib/api';
|
||||||
|
import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types';
|
||||||
interface Application {
|
import {
|
||||||
id: string;
|
Loader2,
|
||||||
name: string;
|
RefreshCw,
|
||||||
createdAt: string;
|
AlertCircle,
|
||||||
}
|
FileText,
|
||||||
|
ChevronLeft,
|
||||||
interface LogFilter {
|
ChevronRight,
|
||||||
appId: string;
|
} from 'lucide-react';
|
||||||
level: string;
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
search: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
level: string;
|
|
||||||
message: string;
|
|
||||||
app?: string;
|
|
||||||
pod?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_LEVELS = [
|
const LOG_LEVELS = [
|
||||||
{ value: '', label: 'همه سطوح', color: 'bg-gray-100' },
|
{ value: '', label: 'All levels' },
|
||||||
{ value: 'error', label: 'Error', color: 'bg-red-100 text-red-800' },
|
{ value: 'error', label: 'Error' },
|
||||||
{ value: 'warn', label: 'Warning', color: 'bg-yellow-100 text-yellow-800' },
|
{ value: 'warn', label: 'Warning' },
|
||||||
{ value: 'info', label: 'Info', color: 'bg-blue-100 text-blue-800' },
|
{ value: 'info', label: 'Info' },
|
||||||
{ value: 'debug', label: 'Debug', color: 'bg-gray-100 text-gray-800' },
|
{ value: 'debug', label: 'Debug' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKLOADS = [
|
||||||
|
{ value: '', label: 'All sources' },
|
||||||
|
{ value: 'app', label: 'Application' },
|
||||||
|
{ value: 'redis', label: 'Redis' },
|
||||||
|
{ value: 'rabbitmq', label: 'RabbitMQ' },
|
||||||
|
{ value: 'database', label: 'Database' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TIME_RANGES = [
|
const TIME_RANGES = [
|
||||||
{ value: '1h', label: 'ساعت گذشته' },
|
{ value: '1h', label: 'Last hour' },
|
||||||
{ value: '6h', label: '6 ساعت گذشته' },
|
{ value: '6h', label: 'Last 6 hours' },
|
||||||
{ value: '24h', label: '24 ساعت گذشته' },
|
{ value: '24h', label: 'Last 24 hours' },
|
||||||
{ value: '7d', label: 'هفته گذشته' },
|
{ value: '7d', label: 'Last 7 days' },
|
||||||
{ value: 'custom', label: 'بازه دلخواه' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function LogsPage() {
|
function levelBadgeClass(level: string) {
|
||||||
const [filters, setFilters] = useState<LogFilter>({
|
switch (level?.toLowerCase()) {
|
||||||
appId: '',
|
case 'error':
|
||||||
level: '',
|
return 'bg-red-100 text-red-800';
|
||||||
from: '',
|
case 'warn':
|
||||||
to: '',
|
case 'warning':
|
||||||
search: '',
|
return 'bg-amber-100 text-amber-800';
|
||||||
});
|
case 'info':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsPageContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialAppId = searchParams.get('appId') || '';
|
||||||
|
|
||||||
|
const [appId, setAppId] = useState(initialAppId);
|
||||||
|
const [workload, setWorkload] = useState('');
|
||||||
|
const [level, setLevel] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [timeRange, setTimeRange] = useState('24h');
|
const [timeRange, setTimeRange] = useState('24h');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
// Fetch user's applications
|
useEffect(() => {
|
||||||
const { data: applications } = useQuery<Application[]>({
|
if (initialAppId) setAppId(initialAppId);
|
||||||
queryKey: ['applications'],
|
}, [initialAppId]);
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await axios.get('/api/applications');
|
const { data: loggingStatus } = useQuery({
|
||||||
return data;
|
queryKey: ['logs-status'],
|
||||||
},
|
queryFn: () => api.get('/logs/status').then((r) => r.data as { available: boolean }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch logs query
|
const { data: applications = [] } = useQuery<Application[]>({
|
||||||
const { data: logsResult, isLoading, refetch, isFetching } = useQuery({
|
queryKey: ['applications'],
|
||||||
queryKey: ['logs', filters, timeRange],
|
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||||
queryFn: async () => {
|
});
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters.appId) params.append('appId', filters.appId);
|
|
||||||
if (filters.level) params.append('level', filters.level);
|
|
||||||
if (filters.search) params.append('search', filters.search);
|
|
||||||
|
|
||||||
if (timeRange !== 'custom') {
|
const buildTimeRange = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const from = new Date();
|
const from = new Date();
|
||||||
switch (timeRange) {
|
switch (timeRange) {
|
||||||
case '1h': from.setHours(now.getHours() - 1); break;
|
case '1h':
|
||||||
case '6h': from.setHours(now.getHours() - 6); break;
|
from.setHours(now.getHours() - 1);
|
||||||
case '24h': from.setDate(now.getDate() - 1); break;
|
break;
|
||||||
case '7d': from.setDate(now.getDate() - 7); break;
|
case '6h':
|
||||||
}
|
from.setHours(now.getHours() - 6);
|
||||||
params.append('from', from.toISOString());
|
break;
|
||||||
} else {
|
case '7d':
|
||||||
if (filters.from) params.append('from', filters.from);
|
from.setDate(now.getDate() - 7);
|
||||||
if (filters.to) params.append('to', filters.to);
|
break;
|
||||||
|
default:
|
||||||
|
from.setDate(now.getDate() - 1);
|
||||||
}
|
}
|
||||||
|
return { from: from.toISOString(), to: now.toISOString() };
|
||||||
|
};
|
||||||
|
|
||||||
const { data } = await axios.get(`/api/logs?${params.toString()}`);
|
const { from, to } = buildTimeRange();
|
||||||
return data;
|
|
||||||
|
const {
|
||||||
|
data: logsResult,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
error,
|
||||||
|
} = useQuery<LogSearchResult>({
|
||||||
|
queryKey: ['logs', appId, workload, level, search, timeRange, page],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (appId) params.set('appId', appId);
|
||||||
|
if (workload) params.set('workload', workload);
|
||||||
|
if (level) params.set('level', level);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
params.set('from', from);
|
||||||
|
params.set('to', to);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('limit', '100');
|
||||||
|
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
enabled: loggingStatus?.available !== false,
|
||||||
refetchInterval: autoRefresh ? 5000 : false,
|
refetchInterval: autoRefresh ? 5000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch log stats
|
const { data: stats } = useQuery<LogStatsResult>({
|
||||||
const { data: logStats } = useQuery({
|
queryKey: ['log-stats', appId, workload, timeRange],
|
||||||
queryKey: ['logStats', filters.appId, timeRange],
|
queryFn: () => {
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters.appId) params.append('appId', filters.appId);
|
if (appId) params.set('appId', appId);
|
||||||
params.append('period', timeRange === 'custom' ? '24h' : timeRange);
|
if (workload) params.set('workload', workload);
|
||||||
|
params.set('period', timeRange);
|
||||||
const { data } = await axios.get(`/api/logs/stats?${params.toString()}`);
|
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
|
enabled: loggingStatus?.available !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch recent errors
|
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
|
||||||
const { data: recentErrors } = useQuery({
|
|
||||||
queryKey: ['recentErrors', filters.appId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters.appId) params.append('appId', filters.appId);
|
|
||||||
params.append('limit', '10');
|
|
||||||
|
|
||||||
const { data } = await axios.get(`/api/logs/errors?${params.toString()}`);
|
if (loggingStatus && !loggingStatus.available) {
|
||||||
return data;
|
return (
|
||||||
},
|
<div className="max-w-3xl mx-auto card p-8 text-center">
|
||||||
});
|
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">Logging not available</h1>
|
||||||
const getLevelBadgeClass = (level: string) => {
|
<p className="text-gray-600 text-sm">
|
||||||
const levelItem = LOG_LEVELS.find(l => l.value === level.toLowerCase());
|
Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app,
|
||||||
return levelItem?.color || 'bg-gray-100';
|
and ask an administrator to deploy the logging stack.
|
||||||
};
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8" dir="rtl">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="mb-8">
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">لاگهای اپلیکیشن</h1>
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
<p className="text-gray-600 mt-2">
|
<FileText className="w-6 h-6" /> Logs
|
||||||
مشاهده و جستجو در لاگهای اپلیکیشنهای خود
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Application, Redis, RabbitMQ, and database logs in one place
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
<div className="card p-4">
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{/* App Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-medium text-gray-600 block mb-1">Application</label>
|
||||||
اپلیکیشن
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={filters.appId}
|
value={appId}
|
||||||
onChange={(e) => setFilters({ ...filters, appId: e.target.value })}
|
onChange={(e) => {
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
setAppId(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input w-full text-sm"
|
||||||
>
|
>
|
||||||
<option value="">همه اپلیکیشنها</option>
|
<option value="">All applications</option>
|
||||||
{applications?.map((app) => (
|
{applications.map((app) => (
|
||||||
<option key={app.id} value={app.id}>
|
<option key={app.id} value={app.id}>
|
||||||
{app.name}
|
{app.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Level Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-medium text-gray-600 block mb-1">Source</label>
|
||||||
سطح لاگ
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={filters.level}
|
value={workload}
|
||||||
onChange={(e) => setFilters({ ...filters, level: e.target.value })}
|
onChange={(e) => {
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
setWorkload(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input w-full text-sm"
|
||||||
>
|
>
|
||||||
{LOG_LEVELS.map((level) => (
|
{WORKLOADS.map((w) => (
|
||||||
<option key={level.value} value={level.value}>
|
<option key={w.value || 'all'} value={w.value}>
|
||||||
{level.label}
|
{w.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Range */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-medium text-gray-600 block mb-1">Level</label>
|
||||||
بازه زمانی
|
<select
|
||||||
</label>
|
value={level}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLevel(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
>
|
||||||
|
{LOG_LEVELS.map((l) => (
|
||||||
|
<option key={l.value || 'all'} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-600 block mb-1">Time range</label>
|
||||||
<select
|
<select
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={(e) => setTimeRange(e.target.value)}
|
onChange={(e) => {
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
setTimeRange(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input w-full text-sm"
|
||||||
>
|
>
|
||||||
{TIME_RANGES.map((range) => (
|
{TIME_RANGES.map((t) => (
|
||||||
<option key={range.value} value={range.value}>
|
<option key={t.value} value={t.value}>
|
||||||
{range.label}
|
{t.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-medium text-gray-600 block mb-1">Search</label>
|
||||||
جستجو در متن
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filters.search}
|
value={search}
|
||||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
onChange={(e) => {
|
||||||
placeholder="جستجو..."
|
setSearch(e.target.value);
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
setPage(1);
|
||||||
/>
|
}}
|
||||||
</div>
|
placeholder="Search message..."
|
||||||
</div>
|
className="input w-full text-sm"
|
||||||
|
|
||||||
{/* Custom date range */}
|
|
||||||
{timeRange === 'custom' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
از تاریخ
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={filters.from}
|
|
||||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
تا تاریخ
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={filters.to}
|
|
||||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-4">
|
||||||
|
<button type="button" onClick={() => refetch()} disabled={isFetching} className="btn-primary text-sm">
|
||||||
|
{isFetching ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 inline animate-spin mr-1" /> Loading
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 inline mr-1" /> Refresh
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center gap-4 mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isFetching ? 'در حال بارگذاری...' : 'بروزرسانی'}
|
|
||||||
</button>
|
</button>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoRefresh}
|
checked={autoRefresh}
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-700">بروزرسانی خودکار (هر 5 ثانیه)</span>
|
Auto-refresh (5s)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{stats && (
|
||||||
{logStats && (
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="card p-4">
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
<p className="text-xs text-gray-500">Total ({stats.period})</p>
|
||||||
<div className="text-sm text-gray-500">کل لاگها</div>
|
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
{logStats.meta?.period || '-'}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card p-4 border-red-100">
|
||||||
|
<p className="text-xs text-red-600">Errors</p>
|
||||||
|
<p className="text-2xl font-bold text-red-700">{stats.errors}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-4 border-red-200">
|
<div className="card p-4 border-amber-100">
|
||||||
<div className="text-sm text-red-500">خطاها</div>
|
<p className="text-xs text-amber-600">Warnings</p>
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<p className="text-2xl font-bold text-amber-700">{stats.warnings}</p>
|
||||||
{recentErrors?.meta?.limit || 0}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="card p-4">
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-4 border-yellow-200">
|
<p className="text-xs text-gray-500">Sources</p>
|
||||||
<div className="text-sm text-yellow-600">هشدارها</div>
|
<p className="text-sm font-mono text-gray-800 mt-1">
|
||||||
<div className="text-2xl font-bold text-yellow-600">-</div>
|
{Object.entries(stats.byWorkload || {})
|
||||||
</div>
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
.join(' · ') || '—'}
|
||||||
<div className="text-sm text-gray-500">اپلیکیشن فعال</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
{filters.appId
|
|
||||||
? applications?.find(a => a.id === filters.appId)?.name || '-'
|
|
||||||
: `${applications?.length || 0} اپلیکیشن`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Query Info */}
|
|
||||||
{logsResult && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
|
||||||
<h3 className="font-medium text-blue-900 mb-2">اطلاعات کوئری</h3>
|
|
||||||
<p className="text-sm text-blue-700 mb-2">{logsResult.usage?.description}</p>
|
|
||||||
<div className="bg-white rounded-lg p-3 font-mono text-sm overflow-x-auto" dir="ltr">
|
|
||||||
<pre>{JSON.stringify(logsResult.query, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-600 mt-2">
|
|
||||||
💡 این کوئری را میتوانید در Elasticsearch یا Kibana اجرا کنید.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logs Table */}
|
{error && (
|
||||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
<div className="card p-4 border-red-200 bg-red-50 text-red-800 text-sm">
|
||||||
<div className="px-6 py-4 border-b flex items-center justify-between">
|
Failed to load logs. Ensure logging is enabled on your application and Elasticsearch is running.
|
||||||
<h2 className="font-semibold text-gray-900">لاگها</h2>
|
</div>
|
||||||
{isFetching && (
|
)}
|
||||||
<span className="text-sm text-gray-500 flex items-center gap-2">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<div className="card overflow-hidden">
|
||||||
در حال بارگذاری...
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Log entries</h2>
|
||||||
|
{logsResult && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{logsResult.total} total · page {page}/{totalPages}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-8 text-center text-gray-500">
|
<div className="p-12 text-center text-gray-500">
|
||||||
در حال بارگذاری لاگها...
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2" />
|
||||||
|
Loading logs...
|
||||||
|
</div>
|
||||||
|
) : !logsResult?.hits?.length ? (
|
||||||
|
<div className="p-12 text-center text-gray-500 text-sm">
|
||||||
|
No logs found for the selected filters.
|
||||||
|
{!appId && <p className="mt-2">Deploy an app with logging enabled to start collecting logs.</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||||
<table className="w-full">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Time</th>
|
||||||
زمان
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Level</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">App</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Source</th>
|
||||||
سطح
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Message</th>
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
|
||||||
اپلیکیشن
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
|
||||||
پیام
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{/* Sample rows - in production, this would come from actual ES query results */}
|
{logsResult.hits.map((entry: LogEntry) => (
|
||||||
<tr className="hover:bg-gray-50">
|
<tr key={entry.id} className="hover:bg-gray-50 align-top">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 font-mono" dir="ltr">
|
<td className="px-3 py-2 font-mono text-xs text-gray-600 whitespace-nowrap">
|
||||||
{new Date().toISOString().slice(0, 19).replace('T', ' ')}
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${getLevelBadgeClass('info')}`}>
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${levelBadgeClass(entry.level)}`}>
|
||||||
INFO
|
{entry.level?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
<td className="px-3 py-2 text-gray-800">{entry.applicationName || '—'}</td>
|
||||||
نمونه اپلیکیشن
|
<td className="px-3 py-2 text-gray-600 capitalize">{entry.workload || 'app'}</td>
|
||||||
</td>
|
<td className="px-3 py-2 font-mono text-xs text-gray-800 break-all max-w-xl">
|
||||||
<td className="px-4 py-3 text-sm text-gray-600 font-mono" dir="ltr">
|
{entry.message}
|
||||||
برای مشاهده لاگها، کوئری بالا را در Elasticsearch اجرا کنید
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination info */}
|
{logsResult && logsResult.total > logsResult.limit && (
|
||||||
{logsResult?.meta && (
|
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||||
<div className="px-6 py-4 border-t bg-gray-50 text-sm text-gray-500">
|
<button
|
||||||
صفحه {logsResult.meta.page} | {logsResult.meta.limit} آیتم در هر صفحه
|
type="button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
className="btn-secondary text-sm disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 inline" /> Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="btn-secondary text-sm disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="w-4 h-4 inline" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Errors */}
|
|
||||||
{recentErrors && (
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden mt-6">
|
|
||||||
<div className="px-6 py-4 border-b bg-red-50">
|
|
||||||
<h2 className="font-semibold text-red-900">آخرین خطاها</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
برای مشاهده خطاهای اخیر، کوئری زیر را در Elasticsearch اجرا کنید:
|
|
||||||
</p>
|
|
||||||
<div className="bg-gray-100 rounded-lg p-3 mt-2 font-mono text-sm overflow-x-auto" dir="ltr">
|
|
||||||
<pre>{JSON.stringify(recentErrors.query, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Kibana Link */}
|
|
||||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6 mt-6">
|
|
||||||
<h3 className="font-semibold text-purple-900 mb-2">🔍 مشاهده در Kibana</h3>
|
|
||||||
<p className="text-purple-700 mb-4">
|
|
||||||
برای تجربه بهتر در مشاهده و آنالیز لاگها، از Kibana استفاده کنید.
|
|
||||||
</p>
|
|
||||||
<div className="bg-white rounded-lg p-3 font-mono text-sm" dir="ltr">
|
|
||||||
kubectl port-forward svc/kibana 5601:5601 -n logging
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-purple-600 mt-2">
|
|
||||||
سپس به آدرس http://localhost:5601 بروید
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}>
|
||||||
|
<LogsPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ export interface ServiceAccessConnection {
|
|||||||
url?: string;
|
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<string, number>;
|
||||||
|
byWorkload: Record<string, number>;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServiceAccessGrant {
|
export interface ServiceAccessGrant {
|
||||||
id: string;
|
id: string;
|
||||||
target: ServiceAccessTarget;
|
target: ServiceAccessTarget;
|
||||||
|
|||||||
Reference in New Issue
Block a user