Compare commits

...

5 Commits

Author SHA1 Message Date
Paul Payne
f42ab47972 Rework service list view. 2026-01-11 00:18:53 +00:00
Paul Payne
41ff618a61 Adds service versions to manifests. 2026-01-11 00:13:31 +00:00
Paul Payne
9b9455e339 Display additional status on service list. 2026-01-10 23:55:29 +00:00
Paul Payne
8ae873ae19 Adds deployment name to service manifest to remove hardcoding in daemon. 2026-01-10 23:55:05 +00:00
Paul Payne
7f863d8226 Add crowdsec 2026-01-10 23:16:16 +00:00
37 changed files with 1254 additions and 146 deletions

View File

@@ -22,9 +22,6 @@ func (m *Manager) UpdateConfig(instanceName, serviceName string, update contract
}
namespace := manifest.Namespace
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
}
// 2. Load instance config
configPath := tools.GetInstanceConfigPath(m.dataDir, instanceName)

View File

@@ -21,9 +21,6 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi
}
namespace := manifest.Namespace
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
}
// 2. Get kubeconfig path
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
@@ -125,9 +122,6 @@ func (m *Manager) StreamLogs(instanceName, serviceName string, opts contracts.Se
}
namespace := manifest.Namespace
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
}
// 2. Get kubeconfig path
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)

View File

@@ -15,7 +15,9 @@ import (
type ServiceManifest struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
Namespace string `yaml:"namespace" json:"namespace"`
DeploymentName string `yaml:"deploymentName,omitempty" json:"deploymentName,omitempty"` // Optional: defaults to Name
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Dependencies []string `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
ConfigReferences []string `yaml:"configReferences,omitempty" json:"configReferences,omitempty"`
@@ -87,10 +89,11 @@ func LoadAllManifests(servicesDir string) (map[string]*ServiceManifest, error) {
}
// GetDeploymentName returns the primary deployment name for this service
// Uses name as the deployment name by default
// Uses DeploymentName if set, otherwise defaults to Name
func (m *ServiceManifest) GetDeploymentName() string {
// For now, assume deployment name matches service name
// This can be made configurable if needed
if m.DeploymentName != "" {
return m.DeploymentName
}
return m.Name
}

View File

@@ -51,7 +51,9 @@ func NewManager(dataDir string) *Manager {
manifests[serviceName] = &ServiceManifest{
Name: manifest.Name,
Description: manifest.Description,
Version: manifest.Version,
Namespace: manifest.Namespace,
DeploymentName: manifest.DeploymentName,
Category: manifest.Category,
Dependencies: manifest.Dependencies,
ConfigReferences: manifest.ConfigReferences,
@@ -86,27 +88,8 @@ var BaseServices = []string{
"longhorn", // Storage
}
// serviceDeployments maps service directory names to their actual namespace and deployment name
var serviceDeployments = map[string]struct {
namespace string
deploymentName string
}{
"cert-manager": {"cert-manager", "cert-manager"},
"coredns": {"kube-system", "coredns"},
"docker-registry": {"docker-registry", "docker-registry"},
"externaldns": {"externaldns", "external-dns"},
"kubernetes-dashboard": {"kubernetes-dashboard", "kubernetes-dashboard"},
"longhorn": {"longhorn-system", "longhorn-ui"},
"metallb": {"metallb-system", "controller"},
"nfs": {"nfs-system", "nfs-server"},
"node-feature-discovery": {"node-feature-discovery", "node-feature-discovery-master"},
"nvidia-device-plugin": {"nvidia-device-plugin", "nvidia-device-plugin-daemonset"},
"smtp": {"smtp-system", "smtp"},
"traefik": {"traefik", "traefik"},
"utils": {"utils-system", "utils"},
}
// checkServiceStatus checks if a service is deployed
// checkServiceStatus checks the deployment status of a service
// Returns: "not-deployed", "deployed", "degraded", or "progressing"
func (m *Manager) checkServiceStatus(instanceName, serviceName string) string {
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
@@ -127,26 +110,38 @@ func (m *Manager) checkServiceStatus(instanceName, serviceName string) string {
return "not-deployed"
}
var namespace, deploymentName string
// Check hardcoded map first for deployment name (has correct names)
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
deploymentName = deployment.deploymentName
} else if manifest, ok := m.manifests[serviceName]; ok {
// Fall back to manifest if not in hardcoded map
namespace = manifest.Namespace
deploymentName = manifest.GetDeploymentName()
} else {
// Service not found anywhere, assume not deployed
// Special case: SMTP is configuration-only, no deployment to check
if serviceName == "smtp" {
return "not-deployed"
}
if kubectl.DeploymentExists(deploymentName, namespace) {
return "deployed"
manifest, ok := m.manifests[serviceName]
if !ok {
return "not-deployed"
}
return "not-deployed"
namespace := manifest.Namespace
deploymentName := manifest.GetDeploymentName()
// Get deployment info to check health status
deploymentInfo, err := kubectl.GetDeployment(deploymentName, namespace)
if err != nil {
return "not-deployed"
}
// Determine deployment status based on replica counts
if deploymentInfo.Ready == deploymentInfo.Desired && deploymentInfo.Desired > 0 {
return "deployed"
} else if deploymentInfo.Ready < deploymentInfo.Desired {
if deploymentInfo.Current > deploymentInfo.Desired {
return "progressing"
}
return "degraded"
} else if deploymentInfo.Desired == 0 {
return "deployed" // Scaled to zero is still "deployed"
}
return "deployed"
}
// List returns all base services and their status
@@ -169,15 +164,12 @@ func (m *Manager) List(instanceName string) ([]Service, error) {
if manifest, ok := m.manifests[name]; ok {
namespace = manifest.Namespace
description = manifest.Description
version = manifest.Category // Using category as version for now
version = manifest.Version
dependencies = manifest.Dependencies
hasConfig = len(manifest.ServiceConfig) > 0
} else {
// Fall back to hardcoded map
namespace = name + "-system" // default
if deployment, ok := serviceDeployments[name]; ok {
namespace = deployment.namespace
}
// Service not in manifests, skip
continue
}
service := Service{
@@ -198,16 +190,15 @@ func (m *Manager) List(instanceName string) ([]Service, error) {
// Get returns a specific service
func (m *Manager) Get(instanceName, serviceName string) (*Service, error) {
// Get the correct namespace from the map
namespace := serviceName + "-system" // default
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
manifest, ok := m.manifests[serviceName]
if !ok {
return nil, fmt.Errorf("service not found: %s", serviceName)
}
service := &Service{
Name: serviceName,
Status: m.checkServiceStatus(instanceName, serviceName),
Namespace: namespace,
Namespace: manifest.Namespace,
}
return service, nil
@@ -284,15 +275,14 @@ func (m *Manager) Delete(instanceName, serviceName string) error {
// GetStatus returns detailed status for a service
func (m *Manager) GetStatus(instanceName, serviceName string) (*Service, error) {
// Get the correct namespace from the map
namespace := serviceName + "-system" // default
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
manifest, ok := m.manifests[serviceName]
if !ok {
return nil, fmt.Errorf("service not found: %s", serviceName)
}
service := &Service{
Name: serviceName,
Namespace: namespace,
Namespace: manifest.Namespace,
Status: m.checkServiceStatus(instanceName, serviceName),
}

View File

@@ -23,12 +23,6 @@ func (m *Manager) GetDetailedStatus(instanceName, serviceName string) (*contract
namespace := manifest.Namespace
deploymentName := manifest.GetDeploymentName()
// Check hardcoded map for correct deployment name
if deployment, ok := serviceDeployments[serviceName]; ok {
namespace = deployment.namespace
deploymentName = deployment.deploymentName
}
// 2. Get kubeconfig path
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
if !storage.FileExists(kubeconfigPath) {

View File

@@ -1,5 +1,6 @@
name: cert-manager
description: X.509 certificate management for Kubernetes
version: v1.17.2
namespace: cert-manager
category: infrastructure

View File

@@ -1,5 +1,6 @@
name: coredns
description: DNS server for internal cluster DNS resolution
version: v1.12.0
namespace: kube-system
category: infrastructure

View File

@@ -0,0 +1,119 @@
# CrowdSec Security Service
CrowdSec is an open-source security engine that analyzes traffic patterns and blocks malicious actors. This service integrates CrowdSec with Traefik to provide automatic threat detection and rate limiting for all Wild Cloud ingresses.
## Components
- **CrowdSec Agent**: Analyzes traffic patterns, maintains decision lists, and connects to the CrowdSec threat intelligence network
- **Traefik Bouncer**: Integrates with Traefik via ForwardAuth to enforce CrowdSec decisions
- **Security Middlewares**: Traefik middleware for rate limiting and security headers
## Default Protection
After installation, **all ingresses are automatically protected** with:
- Threat detection (blocks known malicious IPs and attack patterns)
- Rate limiting (100 requests per minute per IP)
- Security headers (HSTS, XSS protection, content-type sniffing prevention)
## Configuration
Configuration is stored in `config.yaml` under `cluster.crowdsec`:
```yaml
cluster:
crowdsec:
bouncerReplicas: 2 # Number of bouncer pods for HA
rateLimitAverage: 100 # Requests per minute
rateLimitBurst: 100 # Burst allowance
```
## Secrets
Secrets are stored in `secrets.yaml` under `cluster.crowdsec`:
```yaml
cluster:
crowdsec:
agentPassword: <auto-generated>
bouncerApiKey: <auto-generated>
```
## Opting Out
To disable CrowdSec protection for a specific ingress (e.g., webhooks, health checks):
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
traefik.ingress.kubernetes.io/router.middlewares: ""
```
## Using Only Rate Limiting
To use rate limiting without threat detection:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
traefik.ingress.kubernetes.io/router.middlewares: crowdsec-rate-limit@kubernetescrd
```
## Monitoring
View active decisions (blocked IPs):
```bash
kubectl exec -n crowdsec deploy/crowdsec -- cscli decisions list
```
View registered bouncers:
```bash
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
```
View alerts:
```bash
kubectl exec -n crowdsec deploy/crowdsec -- cscli alerts list
```
View metrics (Prometheus format):
```bash
kubectl port-forward -n crowdsec svc/crowdsec-lapi 6060:6060
curl http://localhost:6060/metrics
```
## Threat Intelligence
CrowdSec includes these detection collections:
- `crowdsecurity/traefik` - Traefik-specific detections
- `crowdsecurity/http-cve` - Known HTTP CVE exploits
- `crowdsecurity/whitelist-good-actors` - Whitelist for known good actors (search engines, etc.)
Enabled scenarios:
- HTTP probing and path traversal detection
- Bad user agent detection
- Sensitive file access attempts
- HTTP crawling detection
- SSH brute force (if exposed)
## Troubleshooting
**Bouncer not connecting to agent:**
```bash
kubectl logs -n crowdsec deploy/traefik-crowdsec-bouncer
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
```
**Check if middleware is applied:**
```bash
kubectl get middleware -n crowdsec
kubectl describe ingressroute -n <app-namespace> <route-name>
```
**View CrowdSec logs:**
```bash
kubectl logs -n crowdsec deploy/crowdsec
```

View File

@@ -0,0 +1,125 @@
#!/bin/bash
set -e
set -o pipefail
# Ensure WILD_INSTANCE is set
if [ -z "${WILD_INSTANCE}" ]; then
echo "ERROR: WILD_INSTANCE is not set"
exit 1
fi
# Ensure WILD_API_DATA_DIR is set
if [ -z "${WILD_API_DATA_DIR}" ]; then
echo "ERROR: WILD_API_DATA_DIR is not set"
exit 1
fi
# Ensure KUBECONFIG is set
if [ -z "${KUBECONFIG}" ]; then
echo "ERROR: KUBECONFIG is not set"
exit 1
fi
INSTANCE_DIR="${WILD_API_DATA_DIR}/instances/${WILD_INSTANCE}"
CLUSTER_SETUP_DIR="${INSTANCE_DIR}/setup/cluster-services"
CROWDSEC_DIR="${CLUSTER_SETUP_DIR}/crowdsec"
SECRETS_FILE="${INSTANCE_DIR}/secrets.yaml"
echo "=== Setting up CrowdSec Security Engine ==="
echo ""
# Check traefik dependency
echo "Verifying Traefik is ready (required for CrowdSec bouncer)..."
kubectl wait --for=condition=Available deployment/traefik -n traefik --timeout=60s 2>/dev/null || {
echo "WARNING: Traefik not ready, but continuing with CrowdSec installation"
echo "Note: CrowdSec bouncer will not work until Traefik is available"
}
# Templates should already be compiled
echo "Using pre-compiled CrowdSec templates..."
if [ ! -d "${CROWDSEC_DIR}/kustomize" ]; then
echo "ERROR: Compiled templates not found at ${CROWDSEC_DIR}/kustomize"
echo "Templates should be compiled before deployment."
exit 1
fi
# Apply CrowdSec manifests using kustomize
echo "Deploying CrowdSec..."
kubectl apply -k ${CROWDSEC_DIR}/kustomize
# Setup CrowdSec agent secret
echo "Creating CrowdSec agent secret..."
AGENT_PASSWORD=$(yq '.cluster.crowdsec.agentPassword' "$SECRETS_FILE" 2>/dev/null | tr -d '"')
if [ -z "$AGENT_PASSWORD" ] || [ "$AGENT_PASSWORD" = "null" ]; then
echo "Generating new agent password..."
AGENT_PASSWORD=$(openssl rand -base64 32)
# Note: The API should have already set this in secrets.yaml during config phase
echo "WARNING: Agent password not found in secrets.yaml"
echo "Using generated password - you may want to persist this"
fi
kubectl create secret generic crowdsec-agent-secret \
--namespace crowdsec \
--from-literal=password="${AGENT_PASSWORD}" \
--dry-run=client -o yaml | kubectl apply -f -
# Wait for CrowdSec agent to be ready
echo "Waiting for CrowdSec agent to be ready..."
kubectl rollout status deployment/crowdsec -n crowdsec --timeout=120s
# Register bouncer with CrowdSec agent and create bouncer secret
echo "Registering bouncer with CrowdSec agent..."
BOUNCER_API_KEY=$(yq '.cluster.crowdsec.bouncerApiKey' "$SECRETS_FILE" 2>/dev/null | tr -d '"')
if [ -z "$BOUNCER_API_KEY" ] || [ "$BOUNCER_API_KEY" = "null" ]; then
echo "Generating new bouncer API key from CrowdSec agent..."
# Remove existing bouncer if it exists
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers delete traefik-bouncer 2>/dev/null || true
# Add new bouncer and capture the key
BOUNCER_API_KEY=$(kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers add traefik-bouncer -o raw)
echo "Generated bouncer API key - you may want to persist this in secrets.yaml"
fi
kubectl create secret generic crowdsec-bouncer-secret \
--namespace crowdsec \
--from-literal=api-key="${BOUNCER_API_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
# Restart bouncer to pick up the secret
echo "Restarting bouncer deployment..."
kubectl rollout restart deployment/traefik-crowdsec-bouncer -n crowdsec
# Wait for bouncer to be ready
echo "Waiting for CrowdSec bouncer to be ready..."
kubectl rollout status deployment/traefik-crowdsec-bouncer -n crowdsec --timeout=60s
# Patch Traefik to use CrowdSec middleware by default on websecure entrypoint
echo "Configuring Traefik to use CrowdSec security chain by default..."
kubectl patch deployment traefik -n traefik --type='json' -p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/args/-",
"value": "--entryPoints.websecure.http.middlewares=crowdsec-security-chain@kubernetescrd"
}
]' 2>/dev/null || {
echo "Note: Traefik may already have middleware configured or patch failed"
echo "You can manually configure default middleware if needed"
}
echo ""
echo "CrowdSec installed successfully"
echo ""
echo "All ingresses are now protected by default with:"
echo " - Threat detection (CrowdSec)"
echo " - Rate limiting (100 req/min)"
echo " - Security headers (HSTS, XSS protection, etc.)"
echo ""
echo "To verify the installation:"
echo " kubectl get pods -n crowdsec"
echo " kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list"
echo " kubectl exec -n crowdsec deploy/crowdsec -- cscli decisions list"
echo ""
echo "To opt-out a specific ingress from CrowdSec protection:"
echo " Add annotation: traefik.ingress.kubernetes.io/router.middlewares: \"\""
echo ""

View File

@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik-crowdsec-bouncer
namespace: crowdsec
labels:
app: traefik-crowdsec-bouncer
managedBy: kustomize
partOf: wild-cloud
spec:
replicas: {{ .cluster.crowdsec.bouncerReplicas }}
selector:
matchLabels:
app: traefik-crowdsec-bouncer
managedBy: kustomize
partOf: wild-cloud
template:
metadata:
labels:
app: traefik-crowdsec-bouncer
managedBy: kustomize
partOf: wild-cloud
spec:
containers:
- name: traefik-crowdsec-bouncer
image: fbonalair/traefik-crowdsec-bouncer:latest
env:
- name: CROWDSEC_BOUNCER_API_KEY
valueFrom:
secretKeyRef:
name: crowdsec-bouncer-secret
key: api-key
- name: CROWDSEC_AGENT_HOST
value: "crowdsec-lapi.crowdsec.svc.cluster.local:8080"
- name: CROWDSEC_BOUNCER_LOG_LEVEL
value: "info"
- name: GIN_MODE
value: "release"
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /api/v1/ping
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/v1/ping
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi

View File

@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: traefik-crowdsec-bouncer
namespace: crowdsec
labels:
app: traefik-crowdsec-bouncer
managedBy: kustomize
partOf: wild-cloud
spec:
type: ClusterIP
selector:
app: traefik-crowdsec-bouncer
managedBy: kustomize
partOf: wild-cloud
ports:
- name: http
port: 8080
targetPort: 8080
protocol: TCP

View File

@@ -0,0 +1,42 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: crowdsec-config
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
data:
acquis.yaml: |
# Minimal acquisition config for CrowdSec
# The Traefik bouncer will send data directly via the Local API
source: file
filename: /dev/null
labels:
type: traefik
profiles.yaml: |
name: default_ip_remediation
debug: false
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: 4h
on_success: break
---
name: default_range_remediation
debug: false
filters:
- Alert.Remediation == true && Alert.GetScope() == "Range"
decisions:
- type: ban
duration: 4h
scope: Range
on_success: break
postoverflows.yaml: |
# Post-overflow configuration for crowdsec
name: "rdns"
debug: false
filter: "evt.Enriched.IsoCode != ''"
# Add reverse DNS enrichment

View File

@@ -0,0 +1,110 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: crowdsec
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
replicas: 1
selector:
matchLabels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
template:
metadata:
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
serviceAccountName: crowdsec
securityContext:
runAsUser: 0
runAsNonRoot: false
fsGroup: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: crowdsec
image: crowdsecurity/crowdsec:v1.6.3
env:
- name: COLLECTIONS
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors crowdsecurity/iptables crowdsecurity/linux"
- name: PARSERS
value: "crowdsecurity/traefik-logs crowdsecurity/http-logs crowdsecurity/nginx-logs"
- name: SCENARIOS
value: "crowdsecurity/http-crawl-non_statics crowdsecurity/http-probing crowdsecurity/http-sensitive-files crowdsecurity/http-bad-user-agent crowdsecurity/http-path-traversal-probing crowdsecurity/ssh-bf crowdsecurity/ssh-slow-bf"
- name: POSTOVERFLOWS
value: "crowdsecurity/rdns crowdsecurity/cdn-whitelist"
- name: GID
value: "1000"
- name: LEVEL_TRACE
value: "false"
- name: LEVEL_DEBUG
value: "false"
- name: LEVEL_INFO
value: "true"
- name: AGENT_USERNAME
value: "kubernetes-cluster"
- name: AGENT_PASSWORD
valueFrom:
secretKeyRef:
name: crowdsec-agent-secret
key: password
ports:
- name: lapi
containerPort: 8080
protocol: TCP
- name: prometheus
containerPort: 6060
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 200Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: false
volumeMounts:
- name: crowdsec-config
mountPath: /etc/crowdsec/acquis.yaml
subPath: acquis.yaml
readOnly: true
- name: crowdsec-config
mountPath: /etc/crowdsec/profiles.yaml
subPath: profiles.yaml
readOnly: true
- name: crowdsec-data
mountPath: /var/lib/crowdsec/data
- name: crowdsec-config-dir
mountPath: /etc/crowdsec/config
volumes:
- name: crowdsec-config
configMap:
name: crowdsec-config
- name: crowdsec-data
emptyDir: {}
- name: crowdsec-config-dir
emptyDir: {}

View File

@@ -0,0 +1,24 @@
apiVersion: v1
kind: Service
metadata:
name: crowdsec-lapi
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
type: ClusterIP
selector:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
ports:
- name: lapi
port: 8080
targetPort: 8080
protocol: TCP
- name: prometheus
port: 6060
targetPort: 6060
protocol: TCP

View File

@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: crowdsec
commonLabels:
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- serviceaccount.yaml
- configmap.yaml
- crowdsec-deployment.yaml
- crowdsec-service.yaml
- bouncer-deployment.yaml
- bouncer-service.yaml
- middleware.yaml

View File

@@ -0,0 +1,84 @@
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: crowdsec-bouncer
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
forwardAuth:
address: http://traefik-crowdsec-bouncer.crowdsec.svc.cluster.local:8080/api/v1/forwardAuth
authResponseHeaders:
- X-Crowdsec-Decision
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
rateLimit:
average: {{ .cluster.crowdsec.rateLimitAverage }}
burst: {{ .cluster.crowdsec.rateLimitBurst }}
period: 1m
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
frameDeny: true
sslRedirect: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
addVaryHeader: true
accessControlAllowMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
accessControlAllowOriginList:
- "*"
accessControlMaxAge: 100
customRequestHeaders:
X-Forwarded-Proto: https
customResponseHeaders:
Server: ""
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-chain
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
chain:
middlewares:
- name: security-headers
namespace: crowdsec
- name: rate-limit
namespace: crowdsec
- name: crowdsec-bouncer
namespace: crowdsec

View File

@@ -0,0 +1,8 @@
apiVersion: v1
kind: Namespace
metadata:
name: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud

View File

@@ -0,0 +1,9 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: crowdsec
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud

View File

@@ -0,0 +1,28 @@
name: crowdsec
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
version: v1.6.3
namespace: crowdsec
category: infrastructure
dependencies:
- traefik
configReferences:
- cloud.domain
serviceConfig:
bouncerReplicas:
path: cluster.crowdsec.bouncerReplicas
prompt: "Number of bouncer replicas for high availability"
default: "2"
type: string
rateLimitAverage:
path: cluster.crowdsec.rateLimitAverage
prompt: "Rate limit average requests per minute"
default: "100"
type: string
rateLimitBurst:
path: cluster.crowdsec.rateLimitBurst
prompt: "Rate limit burst requests per minute"
default: "100"
type: string

View File

@@ -20,9 +20,21 @@ spec:
labels:
app: docker-registry
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- image: registry:3.0.0
name: docker-registry
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
ports:
- containerPort: 5000
protocol: TCP

View File

@@ -1,5 +1,6 @@
name: docker-registry
description: Private Docker image registry for cluster
version: "3.0.0"
namespace: docker-registry
category: infrastructure

View File

@@ -1,6 +1,8 @@
name: externaldns
description: Automatically configures DNS records for services
version: v0.13.4
namespace: externaldns
deploymentName: external-dns
category: infrastructure
configReferences:

View File

@@ -1,5 +1,6 @@
name: kubernetes-dashboard
description: Web-based Kubernetes user interface
version: v7.10.0
namespace: kubernetes-dashboard
category: infrastructure

View File

@@ -1,6 +1,8 @@
name: longhorn
description: Cloud-native distributed block storage for Kubernetes
version: v1.8.1
namespace: longhorn-system
deploymentName: longhorn-ui
category: infrastructure
dependencies:

View File

@@ -1,6 +1,8 @@
name: metallb
description: Bare metal load-balancer for Kubernetes
version: v0.15.0
namespace: metallb-system
deploymentName: controller
category: infrastructure
configReferences:

View File

@@ -1,6 +1,7 @@
name: nfs
description: NFS client provisioner for external NFS storage
namespace: nfs-system
version: v4.0.18
namespace: nfs
category: infrastructure
serviceConfig:

View File

@@ -1,4 +1,6 @@
name: node-feature-discovery
description: Detects hardware features available on each node
version: v0.17.3
namespace: node-feature-discovery
deploymentName: node-feature-discovery-master
category: infrastructure

View File

@@ -1,6 +1,8 @@
name: nvidia-device-plugin
description: NVIDIA device plugin for Kubernetes
namespace: nvidia-device-plugin
version: v0.17.1
namespace: kube-system
deploymentName: nvidia-device-plugin-daemonset
category: infrastructure
dependencies:

View File

@@ -1,6 +1,6 @@
name: smtp
description: SMTP relay service for cluster applications
namespace: smtp-system
namespace: smtp
category: infrastructure
serviceConfig:

View File

@@ -1,5 +1,6 @@
name: traefik
description: Cloud-native reverse proxy and ingress controller
version: v3.4
namespace: traefik
category: infrastructure

View File

@@ -3,6 +3,8 @@ apiVersion: v1
kind: Namespace
metadata:
name: debug
labels:
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: v1
kind: ServiceAccount

View File

@@ -1,4 +1,5 @@
name: utils
description: Utility tools and scripts for cluster administration
namespace: utils-system
namespace: debug
deploymentName: netdebug
category: infrastructure

View File

@@ -24,6 +24,7 @@ type ServiceManifest struct {
Version string `yaml:"version"`
Category string `yaml:"category"`
Namespace string `yaml:"namespace"`
DeploymentName string `yaml:"deploymentName,omitempty"` // Optional: defaults to Name
Dependencies []string `yaml:"dependencies,omitempty"`
ConfigReferences []string `yaml:"configReferences,omitempty"`
ServiceConfig map[string]ConfigDefinition `yaml:"serviceConfig,omitempty"`

View File

@@ -2,11 +2,12 @@ import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2, Activity, FileText, Settings, Trash2, Download } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useServices } from '../hooks/useServices';
import type { Service } from '../services/api';
import { ServiceDetailModal } from './services/ServiceDetailModal';
import { ServiceStatusDialog } from './services/ServiceStatusDialog';
import { ServiceLogsDialog } from './services/ServiceLogsDialog';
import { ServiceConfigEditor } from './services/ServiceConfigEditor';
import { Dialog, DialogContent } from './ui/dialog';
import { usePageHelp } from '../hooks/usePageHelp';
@@ -25,7 +26,8 @@ export function ClusterServicesComponent() {
isDeleting
} = useServices(currentInstance);
const [selectedService, setSelectedService] = useState<string | null>(null);
const [statusService, setStatusService] = useState<string | null>(null);
const [logsService, setLogsService] = useState<string | null>(null);
const [configService, setConfigService] = useState<string | null>(null);
usePageHelp({
@@ -83,9 +85,11 @@ export function ClusterServicesComponent() {
available: 'secondary',
deploying: 'default',
installing: 'default',
progressing: 'default',
running: 'success',
ready: 'success',
deployed: 'success',
degraded: 'destructive',
error: 'destructive',
};
@@ -94,8 +98,10 @@ export function ClusterServicesComponent() {
available: 'Available',
deploying: 'Deploying',
installing: 'Installing',
progressing: 'Progressing',
running: 'Running',
ready: 'Ready',
degraded: 'Degraded',
error: 'Error',
deployed: 'Deployed',
};
@@ -210,73 +216,90 @@ export function ClusterServicesComponent() {
<p className="text-muted-foreground">Loading services...</p>
</Card>
) : (
<div className="space-y-4">
<div className="space-y-3">
{services.map((service) => (
<div key={service.name}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<Card key={service.name} className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
{getServiceIcon(service.name)}
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{service.name}</h3>
<h3 className="font-medium truncate">{service.name}</h3>
{service.version && (
<Badge variant="outline" className="text-xs">
{service.version}
</Badge>
)}
{getStatusIcon(typeof service.status === 'string' ? service.status : service.status?.status)}
{getStatusBadge(service)}
</div>
<p className="text-sm text-muted-foreground">{service.description}</p>
<p className="text-sm text-muted-foreground mb-2">{service.description}</p>
{typeof service.status === 'object' && service.status?.message && (
<p className="text-xs text-muted-foreground mt-1">{service.status.message}</p>
<p className="text-xs text-muted-foreground mb-2">{service.status.message}</p>
)}
</div>
<div className="flex items-center gap-3">
{getStatusBadge(service)}
{((typeof service.status === 'string' && service.status === 'not-deployed') ||
(!service.status || service.status === 'not-deployed') ||
(typeof service.status === 'object' && service.status?.status === 'not-deployed')) && (
<Button
size="sm"
onClick={() => handleInstallService(service.name)}
disabled={isInstalling}
>
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Install'}
</Button>
)}
{((typeof service.status === 'string' && service.status === 'deployed') ||
(typeof service.status === 'object' && service.status?.status === 'deployed')) && (
<>
{/* Action buttons - horizontal layout with responsive icons */}
<div className="flex flex-wrap gap-2 mt-3">
{((typeof service.status === 'string' && service.status === 'not-deployed') ||
(!service.status || (typeof service.status === 'string' && service.status === 'not-deployed')) ||
(typeof service.status === 'object' && service.status?.status === 'not-deployed')) && (
<Button
size="sm"
variant="outline"
onClick={() => setSelectedService(service.name)}
onClick={() => handleInstallService(service.name)}
disabled={isInstalling}
>
View
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin sm:mr-1" /> : <Download className="h-4 w-4 sm:mr-1" />}
<span className="hidden sm:inline">Install</span>
</Button>
{service.hasConfig && (
)}
{((typeof service.status === 'string' && ['deployed', 'degraded', 'progressing'].includes(service.status)) ||
(typeof service.status === 'object' && ['deployed', 'degraded', 'progressing'].includes(service.status?.status || ''))) && (
<>
<Button
size="sm"
variant="outline"
onClick={() => setConfigService(service.name)}
onClick={() => setStatusService(service.name)}
title="Status"
>
Configure
<Activity className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">Status</span>
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteService(service.name)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
onClick={() => setLogsService(service.name)}
title="Logs"
>
<FileText className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">Logs</span>
</Button>
{service.hasConfig && (
<Button
size="sm"
variant="outline"
onClick={() => setConfigService(service.name)}
title="Configure"
>
<Settings className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">Configure</span>
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteService(service.name)}
disabled={isDeleting}
title="Remove"
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</>
)}
</div>
</div>
</div>
</div>
</Card>
))}
{services.length === 0 && (
@@ -292,34 +315,21 @@ export function ClusterServicesComponent() {
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">Cluster Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<div className="font-medium mb-2">Control Plane</div>
<div className="space-y-1 text-muted-foreground">
<div> API Server: https://cluster.wildcloud.local:6443</div>
<div> Nodes: 1 controller, 2 workers</div>
<div> Version: Kubernetes v1.29.0</div>
</div>
</div>
<div>
<div className="font-medium mb-2">Network Configuration</div>
<div className="space-y-1 text-muted-foreground">
<div> Pod CIDR: 10.244.0.0/16</div>
<div> Service CIDR: 10.96.0.0/12</div>
<div> CNI: Cilium v1.14.5</div>
</div>
</div>
</div>
</Card>
{selectedService && (
<ServiceDetailModal
{statusService && (
<ServiceStatusDialog
instanceName={currentInstance}
serviceName={selectedService}
open={!!selectedService}
onClose={() => setSelectedService(null)}
serviceName={statusService}
open={!!statusService}
onClose={() => setStatusService(null)}
/>
)}
{logsService && (
<ServiceLogsDialog
instanceName={currentInstance}
serviceName={logsService}
open={!!logsService}
onClose={() => setLogsService(null)}
/>
)}

View File

@@ -0,0 +1,244 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { ServiceStatusBadge } from './ServiceStatusBadge';
import { useServiceStatus } from '@/hooks/useServices';
import { servicesApi } from '@/services/api';
import { Copy, Download, RefreshCw } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface ServiceLogsDialogProps {
instanceName: string;
serviceName: string;
open: boolean;
onClose: () => void;
}
export function ServiceLogsDialog({
instanceName,
serviceName,
open,
onClose,
}: ServiceLogsDialogProps) {
const { data: status } = useServiceStatus(instanceName, serviceName);
const [logs, setLogs] = useState<string[]>([]);
const [follow, setFollow] = useState(false);
const [tail, setTail] = useState(100);
const [autoScroll, setAutoScroll] = useState(true);
const logsEndRef = useRef<HTMLDivElement>(null);
const logsContainerRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// Scroll to bottom when logs change and autoScroll is enabled
useEffect(() => {
if (autoScroll && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, autoScroll]);
// Fetch initial buffered logs
const fetchLogs = useCallback(async () => {
if (!open) return;
try {
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, false);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch logs: ${response.statusText}`);
}
const data = await response.json();
// API returns { lines: string[] }
if (data.lines && Array.isArray(data.lines)) {
setLogs(data.lines);
} else {
setLogs([]);
}
} catch (error) {
console.error('Error fetching logs:', error);
setLogs([`Error: ${error instanceof Error ? error.message : 'Failed to fetch logs'}`]);
}
}, [instanceName, serviceName, tail, open]);
// Set up SSE streaming when follow is enabled
useEffect(() => {
if (!open) return;
if (follow) {
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, true);
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.onmessage = (event) => {
const line = event.data;
if (line && line.trim() !== '') {
setLogs((prev) => [...prev, line]);
}
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
setFollow(false);
};
return () => {
eventSource.close();
};
} else {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
}, [follow, instanceName, serviceName, tail, open]);
// Fetch initial logs on mount and when parameters change
useEffect(() => {
if (open && !follow) {
fetchLogs();
}
}, [fetchLogs, follow, open]);
// Clean up on close
useEffect(() => {
if (!open) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setFollow(false);
}
}, [open]);
const handleCopyLogs = () => {
const text = logs.join('\n');
navigator.clipboard.writeText(text);
};
const handleDownloadLogs = () => {
const text = logs.join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${serviceName}-logs.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleClearLogs = () => {
setLogs([]);
};
const handleRefresh = () => {
setLogs([]);
fetchLogs();
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
{serviceName}
{status && <ServiceStatusBadge status={status.deploymentStatus} />}
</DialogTitle>
<DialogDescription>Service logs</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2">
<Label htmlFor="tail-select">Lines:</Label>
<Select value={tail.toString()} onValueChange={(v) => setTail(Number(v))}>
<SelectTrigger id="tail-select" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="500">500</SelectItem>
<SelectItem value="1000">1000</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="follow-checkbox"
checked={follow}
onChange={(e) => setFollow(e.target.checked)}
className="rounded"
/>
<Label htmlFor="follow-checkbox">Follow</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoscroll-checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
<Label htmlFor="autoscroll-checkbox">Auto-scroll</Label>
</div>
<div className="flex gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={follow}>
<RefreshCw className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">Refresh</span>
</Button>
<Button variant="outline" size="sm" onClick={handleCopyLogs}>
<Copy className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">Copy</span>
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadLogs}>
<Download className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">Download</span>
</Button>
<Button variant="outline" size="sm" onClick={handleClearLogs}>
Clear
</Button>
</div>
</div>
<Card className="flex-1 overflow-hidden">
<CardContent className="p-0 h-full">
<div
ref={logsContainerRef}
className="h-[400px] overflow-y-auto bg-slate-950 dark:bg-slate-900 p-4 font-mono text-xs text-green-400"
>
{logs.length === 0 ? (
<div className="text-slate-500">No logs available</div>
) : (
logs.map((line, index) => (
<div key={index} className="whitespace-pre-wrap break-all">
{line}
</div>
))
)}
<div ref={logsEndRef} />
</div>
</CardContent>
</Card>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,197 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ServiceStatusBadge } from './ServiceStatusBadge';
import { useServiceStatus } from '@/hooks/useServices';
import { RefreshCw } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
interface ServiceStatusDialogProps {
instanceName: string;
serviceName: string;
open: boolean;
onClose: () => void;
}
export function ServiceStatusDialog({
instanceName,
serviceName,
open,
onClose,
}: ServiceStatusDialogProps) {
const { data: status, isLoading, refetch } = useServiceStatus(instanceName, serviceName);
const getPodStatusColor = (status: string) => {
if (status.toLowerCase().includes('running')) return 'text-green-600 dark:text-green-400';
if (status.toLowerCase().includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
if (status.toLowerCase().includes('failed')) return 'text-red-600 dark:text-red-400';
return 'text-muted-foreground';
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
{serviceName}
{status && <ServiceStatusBadge status={status.deploymentStatus} />}
</DialogTitle>
<DialogDescription>Service status and details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : status ? (
<>
{/* Status Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Status Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Service Name</p>
<p className="text-sm">{status.name}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
<p className="text-sm">{status.namespace}</p>
</div>
</div>
{status.replicas && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Replicas</p>
<div className="grid grid-cols-4 gap-2 text-sm">
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Desired</p>
<p className="font-semibold">{status.replicas.desired}</p>
</div>
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Current</p>
<p className="font-semibold">{status.replicas.current}</p>
</div>
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Ready</p>
<p className="font-semibold">{status.replicas.ready}</p>
</div>
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Available</p>
<p className="font-semibold">{status.replicas.available}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Pods Section */}
{status.pods && status.pods.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Pods</CardTitle>
<CardDescription>{status.pods.length} pod(s)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{status.pods.map((pod) => (
<div
key={pod.name}
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{pod.name}</p>
{pod.node && (
<p className="text-xs text-muted-foreground">Node: {pod.node}</p>
)}
</div>
<div className="flex gap-2 ml-2">
<Badge variant="outline" className={getPodStatusColor(pod.status)}>
{pod.status}
</Badge>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Ready:</span>{' '}
<span className="font-medium">{pod.ready}</span>
</div>
<div>
<span className="text-muted-foreground">Restarts:</span>{' '}
<span className="font-medium">{pod.restarts}</span>
</div>
<div>
<span className="text-muted-foreground">Age:</span>{' '}
<span className="font-medium">{pod.age}</span>
</div>
</div>
{pod.ip && (
<div className="text-xs mt-1">
<span className="text-muted-foreground">IP:</span>{' '}
<span className="font-mono">{pod.ip}</span>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Configuration Preview */}
{status.config && Object.keys(status.config).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Configuration</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{Object.entries(status.config).map(([key, value]) => (
<div key={key} className="flex justify-between text-sm">
<span className="font-medium text-muted-foreground">{key}:</span>
<span className="font-mono text-xs">
{typeof value === 'object' && value !== null
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
) : (
<p className="text-center text-muted-foreground py-8">No status information available</p>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,3 +2,5 @@ export { ServiceStatusBadge } from './ServiceStatusBadge';
export { ServiceLogViewer } from './ServiceLogViewer';
export { ServiceConfigEditor } from './ServiceConfigEditor';
export { ServiceDetailModal } from './ServiceDetailModal';
export { ServiceStatusDialog } from './ServiceStatusDialog';
export { ServiceLogsDialog } from './ServiceLogsDialog';