Improve logging recovery, resource scaling, and app deploy logging.
Auto-reconnect Elasticsearch port-forward after cluster or API restarts, poll log status in the UI, and apply storage changes through billing upgrade for all workloads. Add Redis/RabbitMQ PVC resize, Helm ES credentials for Fluent Bit, and fix deploy progress overlay behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+17
-2
@@ -19,11 +19,26 @@ JWT_REFRESH_EXPIRES_IN=7d
|
|||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# Container Registry
|
# Container Registry (same Docker Registry v2, two hostnames)
|
||||||
REGISTRY_URL=registry.example.com
|
# Internal — Kaniko/build pods push here (ClusterIP, HTTP, fast)
|
||||||
|
REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000
|
||||||
|
# External — kubelet pulls app images; also use for manual "docker push" (Ingress or NodePort)
|
||||||
|
REGISTRY_PULL_URL=repo.3fase.ir
|
||||||
|
# REGISTRY_PULL_URL=10.0.0.50:30500
|
||||||
REGISTRY_USERNAME=admin
|
REGISTRY_USERNAME=admin
|
||||||
REGISTRY_PASSWORD=registry_secret
|
REGISTRY_PASSWORD=registry_secret
|
||||||
|
|
||||||
|
# Central logging (Elasticsearch + Kibana)
|
||||||
|
# In-cluster backend: leave ELASTICSEARCH_HOST unset (uses elasticsearch.logging.svc.cluster.local).
|
||||||
|
# Local backend (npm run dev): API auto-runs kubectl port-forward when host is loopback
|
||||||
|
# ELASTICSEARCH_HOST=127.0.0.1
|
||||||
|
# ELASTICSEARCH_PORT=9200
|
||||||
|
# ELASTICSEARCH_AUTO_PORT_FORWARD=false
|
||||||
|
# ELASTIC_PASSWORD=CloudHost2024!Secure
|
||||||
|
# KIBANA_SYSTEM_PASSWORD=Kibana2024!System
|
||||||
|
# LOGGING_ELASTICSEARCH_IMAGE=localhost:30500/elasticsearch:8.12.0
|
||||||
|
# LOGGING_KIBANA_IMAGE=localhost:30500/kibana:8.12.0
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
BUILD_NAMESPACE=cloudhost-builds
|
BUILD_NAMESPACE=cloudhost-builds
|
||||||
BUILD_SERVICE_ACCOUNT=kaniko-builder
|
BUILD_SERVICE_ACCOUNT=kaniko-builder
|
||||||
|
|||||||
@@ -130,3 +130,21 @@ Default log paths based on runtime
|
|||||||
{{- else }}/var/log/app/*.log
|
{{- else }}/var/log/app/*.log
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Shell start command for stdout capture (must match CloudHost-generated images).
|
||||||
|
*/}}
|
||||||
|
{{- define "cloudhost-app.runtimeStartCommand" -}}
|
||||||
|
{{- if eq .Values.app.runtime "nodejs" -}}
|
||||||
|
if [ -f /app/.mode ] && [ "$(cat /app/.mode)" = "standalone" ] && [ -f server.js ]; then node server.js; else npm start; fi
|
||||||
|
{{- else if eq .Values.app.runtime "go" -}}
|
||||||
|
./main
|
||||||
|
{{- else if eq .Values.app.runtime "dotnet" -}}
|
||||||
|
DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! -name '*.runtimeconfig.dll' | head -1) && dotnet "$DLL"
|
||||||
|
{{- else -}}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "cloudhost-app.loggingWrapEnabled" -}}
|
||||||
|
{{- and .Values.elasticsearch.enabled (include "cloudhost-app.runtimeStartCommand" .) -}}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ spec:
|
|||||||
- name: {{ $name }}
|
- name: {{ $name }}
|
||||||
image: {{ .Values.app.image | quote }}
|
image: {{ .Values.app.image | quote }}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
|
{{- if include "cloudhost-app.loggingWrapEnabled" . }}
|
||||||
|
command: ["sh", "-c"]
|
||||||
|
args:
|
||||||
|
- mkdir -p /var/log/app && ({{ include "cloudhost-app.runtimeStartCommand" . | trim }}) >> /var/log/app/app.log 2>&1
|
||||||
|
{{- end }}
|
||||||
ports:
|
ports:
|
||||||
- containerPort: {{ .Values.app.port }}
|
- containerPort: {{ .Values.app.port }}
|
||||||
{{- if and .Values.envVars (gt (len .Values.envVars) 0) }}
|
{{- if and .Values.envVars (gt (len .Values.envVars) 0) }}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{- if .Values.elasticsearch.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: elasticsearch-credentials
|
||||||
|
namespace: {{ include "cloudhost-app.namespace" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | default "CloudHost2024!Secure" | quote }}
|
||||||
|
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | default "FluentBit2024!Writer" | quote }}
|
||||||
|
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | default "Kibana2024!System" | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -87,6 +87,9 @@ elasticsearch:
|
|||||||
logPaths: []
|
logPaths: []
|
||||||
ownerId: ""
|
ownerId: ""
|
||||||
applicationId: ""
|
applicationId: ""
|
||||||
|
elasticPassword: ""
|
||||||
|
fluentbitPassword: ""
|
||||||
|
kibanaPassword: ""
|
||||||
|
|
||||||
# ── Change metadata ─────────────────────────────────────
|
# ── Change metadata ─────────────────────────────────────
|
||||||
changeCause: ""
|
changeCause: ""
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ clusterName: cloudhost-logs
|
|||||||
|
|
||||||
storage: 50Gi
|
storage: 50Gi
|
||||||
|
|
||||||
|
# Official Elastic images; require docker.elastic.co DNS + outbound HTTPS from nodes.
|
||||||
|
# If pull fails with "lookup docker.elastic.co: Try again", mirror to your registry and override here.
|
||||||
images:
|
images:
|
||||||
elasticsearch: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
|
elasticsearch: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
|
||||||
kibana: docker.elastic.co/kibana/kibana:8.12.0
|
kibana: docker.elastic.co/kibana/kibana:8.12.0
|
||||||
|
|||||||
@@ -199,6 +199,80 @@ export class ApplicationsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/redis-storage')
|
||||||
|
@ApiOperation({ summary: 'Resize (expand) Redis PVC storage' })
|
||||||
|
async resizeRedisStorage(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() body: { size: string },
|
||||||
|
) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||||
|
|
||||||
|
if (!app.enableRedis) {
|
||||||
|
throw new BadRequestException('Redis is not enabled for this application');
|
||||||
|
}
|
||||||
|
if (!body.size || !/^\d+Gi$/.test(body.size)) {
|
||||||
|
throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.kubernetesService.resizeRedisStoragePvc(app, body.size);
|
||||||
|
if (result.success) {
|
||||||
|
const prev = app.optionalServiceResources?.redis;
|
||||||
|
const storageGi = parseInt(body.size.replace('Gi', ''), 10) || 1;
|
||||||
|
await this.applicationsService.update(id, app.userId, {
|
||||||
|
optionalServiceResources: {
|
||||||
|
...app.optionalServiceResources,
|
||||||
|
redis: {
|
||||||
|
cpuRequest: prev?.cpuRequest,
|
||||||
|
cpuLimit: prev?.cpuLimit ?? '200m',
|
||||||
|
memoryRequest: prev?.memoryRequest,
|
||||||
|
memoryLimit: prev?.memoryLimit ?? '256Mi',
|
||||||
|
storageGi,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/rabbitmq-storage')
|
||||||
|
@ApiOperation({ summary: 'Resize (expand) RabbitMQ PVC storage' })
|
||||||
|
async resizeRabbitmqStorage(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() body: { size: string },
|
||||||
|
) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||||
|
|
||||||
|
if (!app.enableRabbitmq) {
|
||||||
|
throw new BadRequestException('RabbitMQ is not enabled for this application');
|
||||||
|
}
|
||||||
|
if (!body.size || !/^\d+Gi$/.test(body.size)) {
|
||||||
|
throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.kubernetesService.resizeRabbitmqStoragePvc(app, body.size);
|
||||||
|
if (result.success) {
|
||||||
|
const prev = app.optionalServiceResources?.rabbitmq;
|
||||||
|
const storageGi = parseInt(body.size.replace('Gi', ''), 10) || 2;
|
||||||
|
await this.applicationsService.update(id, app.userId, {
|
||||||
|
optionalServiceResources: {
|
||||||
|
...app.optionalServiceResources,
|
||||||
|
rabbitmq: {
|
||||||
|
cpuRequest: prev?.cpuRequest,
|
||||||
|
cpuLimit: prev?.cpuLimit ?? '500m',
|
||||||
|
memoryRequest: prev?.memoryRequest,
|
||||||
|
memoryLimit: prev?.memoryLimit ?? '512Mi',
|
||||||
|
storageGi,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List my applications or managed services' })
|
@ApiOperation({ summary: 'List my applications or managed services' })
|
||||||
async findAll(
|
async findAll(
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ export class BillingController {
|
|||||||
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
||||||
const pt = app.productType ?? ProductType.APPLICATION;
|
const pt = app.productType ?? ProductType.APPLICATION;
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_REDIS && dto.redisResources) {
|
if (dto.redisResources) {
|
||||||
return {
|
return {
|
||||||
optionalServiceResources: {
|
optionalServiceResources: {
|
||||||
...app.optionalServiceResources,
|
...app.optionalServiceResources,
|
||||||
@@ -844,7 +844,7 @@ export class BillingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_RABBITMQ && dto.rabbitmqResources) {
|
if (dto.rabbitmqResources) {
|
||||||
return {
|
return {
|
||||||
optionalServiceResources: {
|
optionalServiceResources: {
|
||||||
...app.optionalServiceResources,
|
...app.optionalServiceResources,
|
||||||
@@ -860,6 +860,10 @@ export class BillingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||||
@@ -899,46 +903,39 @@ export class BillingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_REDIS) {
|
if (pt === ProductType.MANAGED_REDIS) {
|
||||||
const res = app.optionalServiceResources?.redis;
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||||
if (res) {
|
|
||||||
await this.kubernetesService.updateResources(
|
|
||||||
app,
|
|
||||||
{
|
|
||||||
cpuRequest: res.cpuRequest,
|
|
||||||
cpuLimit: res.cpuLimit,
|
|
||||||
memoryRequest: res.memoryRequest,
|
|
||||||
memoryLimit: res.memoryLimit,
|
|
||||||
},
|
|
||||||
'redis',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_RABBITMQ) {
|
if (pt === ProductType.MANAGED_RABBITMQ) {
|
||||||
const res = app.optionalServiceResources?.rabbitmq;
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||||
if (res) {
|
|
||||||
await this.kubernetesService.updateResources(
|
|
||||||
app,
|
|
||||||
{
|
|
||||||
cpuRequest: res.cpuRequest,
|
|
||||||
cpuLimit: res.cpuLimit,
|
|
||||||
memoryRequest: res.memoryRequest,
|
|
||||||
memoryLimit: res.memoryLimit,
|
|
||||||
},
|
|
||||||
'rabbitmq',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.kubernetesService.updateResources(app, {
|
if (dto.redisResources && app.enableRedis) {
|
||||||
cpuRequest: dto.cpuRequest,
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||||
cpuLimit: dto.cpuLimit,
|
}
|
||||||
memoryRequest: dto.memoryRequest,
|
|
||||||
memoryLimit: dto.memoryLimit,
|
if (dto.rabbitmqResources && app.enableRabbitmq) {
|
||||||
replicas: dto.replicas,
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const touchesAppWorkload =
|
||||||
|
dto.cpuRequest !== undefined ||
|
||||||
|
dto.cpuLimit !== undefined ||
|
||||||
|
dto.memoryRequest !== undefined ||
|
||||||
|
dto.memoryLimit !== undefined ||
|
||||||
|
dto.replicas !== undefined;
|
||||||
|
|
||||||
|
if (touchesAppWorkload) {
|
||||||
|
await this.kubernetesService.updateResources(app, {
|
||||||
|
cpuRequest: dto.cpuRequest,
|
||||||
|
cpuLimit: dto.cpuLimit,
|
||||||
|
memoryRequest: dto.memoryRequest,
|
||||||
|
memoryLimit: dto.memoryLimit,
|
||||||
|
replicas: dto.replicas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
||||||
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
||||||
@@ -957,6 +954,40 @@ export class BillingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyOptionalServiceUpgrade(
|
||||||
|
app: Application,
|
||||||
|
dto: UpgradeResourcesDto,
|
||||||
|
previous: Application,
|
||||||
|
service: 'redis' | 'rabbitmq',
|
||||||
|
): Promise<void> {
|
||||||
|
const res = app.optionalServiceResources?.[service];
|
||||||
|
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
|
||||||
|
if (res) {
|
||||||
|
await this.kubernetesService.updateResources(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
cpuRequest: res.cpuRequest,
|
||||||
|
cpuLimit: res.cpuLimit,
|
||||||
|
memoryRequest: res.memoryRequest,
|
||||||
|
memoryLimit: res.memoryLimit,
|
||||||
|
},
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prevGi =
|
||||||
|
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
|
||||||
|
const nextGi = dtoRes?.storageGi;
|
||||||
|
if (nextGi != null && nextGi > prevGi) {
|
||||||
|
const resize =
|
||||||
|
service === 'redis'
|
||||||
|
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
|
||||||
|
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
|
||||||
|
if (!resize.success) {
|
||||||
|
throw new BadRequestException(resize.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getAppWithAccess(user: any, applicationId: string) {
|
private async getAppWithAccess(user: any, applicationId: string) {
|
||||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,21 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
elasticsearch: {
|
elasticsearch: {
|
||||||
|
/** API host for log search. Use cluster DNS in-cluster; 127.0.0.1 + port-forward when backend runs locally. */
|
||||||
|
host: process.env.ELASTICSEARCH_HOST || 'elasticsearch.logging.svc.cluster.local',
|
||||||
|
port: parseInt(process.env.ELASTICSEARCH_PORT || '9200', 10),
|
||||||
|
/** In development with loopback host, start kubectl port-forward on API boot (set false to manage manually). */
|
||||||
|
autoPortForward: process.env.ELASTICSEARCH_AUTO_PORT_FORWARD ?? 'true',
|
||||||
password: process.env.ELASTIC_PASSWORD || 'CloudHost2024!Secure',
|
password: process.env.ELASTIC_PASSWORD || 'CloudHost2024!Secure',
|
||||||
fluentbitPassword: process.env.FLUENTBIT_PASSWORD || 'FluentBit2024!Writer',
|
fluentbitPassword: process.env.FLUENTBIT_PASSWORD || 'FluentBit2024!Writer',
|
||||||
kibanaPassword: process.env.KIBANA_SYSTEM_PASSWORD || 'Kibana2024!System',
|
kibanaPassword: process.env.KIBANA_SYSTEM_PASSWORD || 'Kibana2024!System',
|
||||||
|
/** Override when cluster nodes cannot reach docker.elastic.co (mirror to local registry). */
|
||||||
|
images: {
|
||||||
|
elasticsearch:
|
||||||
|
process.env.LOGGING_ELASTICSEARCH_IMAGE ||
|
||||||
|
'docker.elastic.co/elasticsearch/elasticsearch:8.12.0',
|
||||||
|
kibana: process.env.LOGGING_KIBANA_IMAGE || 'docker.elastic.co/kibana/kibana:8.12.0',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
platform: {
|
platform: {
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} 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 { ChildProcess, spawn } from 'child_process';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
|
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
|
||||||
|
|
||||||
@@ -56,12 +65,18 @@ export interface LogStatsResult {
|
|||||||
* that all user apps can send logs to via Fluent Bit sidecars.
|
* that all user apps can send logs to via Fluent Bit sidecars.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ElasticsearchService {
|
export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(ElasticsearchService.name);
|
private readonly logger = new Logger(ElasticsearchService.name);
|
||||||
private readonly ES_NAMESPACE = 'logging';
|
private readonly ES_NAMESPACE = 'logging';
|
||||||
private readonly ES_NAME = 'elasticsearch';
|
private readonly ES_NAME = 'elasticsearch';
|
||||||
private readonly KIBANA_NAME = 'kibana';
|
private readonly KIBANA_NAME = 'kibana';
|
||||||
|
private portForwardChild: ChildProcess | null = null;
|
||||||
|
private portForwardStartedByUs = false;
|
||||||
|
private ensureInFlight: Promise<void> | null = null;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private reconnectAttempt = 0;
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -78,6 +93,254 @@ export class ElasticsearchService {
|
|||||||
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System';
|
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.ensureLocalElasticsearchAccess({ waitForCluster: true });
|
||||||
|
if (this.shouldAutoPortForward()) {
|
||||||
|
this.healthCheckTimer = setInterval(() => {
|
||||||
|
void this.periodicElasticsearchHealthCheck();
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.stopDevPortForward();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLoopbackHost(host: string): boolean {
|
||||||
|
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldAutoPortForward(): boolean {
|
||||||
|
if (this.configService.get<string>('elasticsearch.autoPortForward') === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.env.ELASTICSEARCH_AUTO_PORT_FORWARD === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
if (nodeEnv === 'production') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const host = this.configService.get<string>('elasticsearch.host') || '';
|
||||||
|
return this.isLoopbackHost(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopDevPortForward(): void {
|
||||||
|
if (!this.portForwardChild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedByUs = this.portForwardStartedByUs;
|
||||||
|
const child = this.portForwardChild;
|
||||||
|
this.portForwardChild = null;
|
||||||
|
this.portForwardStartedByUs = false;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
if (startedByUs) {
|
||||||
|
this.logger.log('Stopped Elasticsearch kubectl port-forward');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedulePortForwardReconnect(reason: string): void {
|
||||||
|
if (!this.shouldAutoPortForward()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(60_000, 2_000 * Math.pow(2, this.reconnectAttempt));
|
||||||
|
this.reconnectAttempt += 1;
|
||||||
|
this.logger.warn(
|
||||||
|
`Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`,
|
||||||
|
);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
void this.ensureLocalElasticsearchAccess().then((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async periodicElasticsearchHealthCheck(): Promise<void> {
|
||||||
|
if (!this.shouldAutoPortForward()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deployed = await this.isDeployed();
|
||||||
|
if (!deployed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await this.probeElasticsearch()) {
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug('Elasticsearch health check failed; restoring tunnel…');
|
||||||
|
await this.ensureLocalElasticsearchAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForLoggingStack(maxWaitMs = 120_000): Promise<boolean> {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < maxWaitMs) {
|
||||||
|
if (await this.isDeployed()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 5_000));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeElasticsearch(timeoutMs = 3000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const conn = this.getConnectionInfo();
|
||||||
|
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
|
||||||
|
const response = await fetch(`http://${conn.host}:${conn.port}/_cluster/health`, {
|
||||||
|
headers: { Authorization: `Basic ${auth}` },
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForElasticsearch(maxWaitMs = 15_000): Promise<boolean> {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < maxWaitMs) {
|
||||||
|
if (await this.probeElasticsearch(2000)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startDevPortForward(localPort: number): void {
|
||||||
|
if (this.portForwardChild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const args = [
|
||||||
|
'port-forward',
|
||||||
|
'-n',
|
||||||
|
this.ES_NAMESPACE,
|
||||||
|
`svc/${this.ES_NAME}`,
|
||||||
|
`${localPort}:9200`,
|
||||||
|
];
|
||||||
|
this.logger.log(`Starting kubectl ${args.join(' ')} (local log search)`);
|
||||||
|
const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
this.portForwardChild = child;
|
||||||
|
this.portForwardStartedByUs = true;
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
const wasOurs = this.portForwardChild === child;
|
||||||
|
if (wasOurs) {
|
||||||
|
this.portForwardChild = null;
|
||||||
|
this.portForwardStartedByUs = false;
|
||||||
|
}
|
||||||
|
if (wasOurs) {
|
||||||
|
const reason =
|
||||||
|
code !== 0 && code !== null
|
||||||
|
? `exit code ${code}`
|
||||||
|
: signal
|
||||||
|
? `signal ${signal}`
|
||||||
|
: 'connection closed';
|
||||||
|
this.schedulePortForwardReconnect(reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
const line = chunk.toString().trim();
|
||||||
|
if (line && !line.includes('Handling connection')) {
|
||||||
|
this.logger.debug(`kubectl port-forward: ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the API runs on the host with ELASTICSEARCH_HOST=127.0.0.1, open a tunnel to the cluster.
|
||||||
|
* Safe to call repeatedly (e.g. after cluster/API restart or port-forward drop).
|
||||||
|
*/
|
||||||
|
private async ensureLocalElasticsearchAccess(options?: {
|
||||||
|
waitForCluster?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (this.ensureInFlight) {
|
||||||
|
await this.ensureInFlight;
|
||||||
|
return this.probeElasticsearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureInFlight = this.ensureLocalElasticsearchAccessImpl(options);
|
||||||
|
try {
|
||||||
|
await this.ensureInFlight;
|
||||||
|
return this.probeElasticsearch();
|
||||||
|
} finally {
|
||||||
|
this.ensureInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLocalElasticsearchAccessImpl(options?: {
|
||||||
|
waitForCluster?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.shouldAutoPortForward()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.probeElasticsearch()) {
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deployed = await this.isDeployed();
|
||||||
|
if (!deployed && options?.waitForCluster) {
|
||||||
|
this.logger.log('Waiting for logging stack after cluster reconnect…');
|
||||||
|
deployed = await this.waitForLoggingStack();
|
||||||
|
}
|
||||||
|
if (!deployed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = this.configService.get<number>('elasticsearch.port') || 9200;
|
||||||
|
|
||||||
|
// Stale tunnel after sleep/reboot: port may be bound but ES unreachable
|
||||||
|
if (this.portForwardChild) {
|
||||||
|
this.stopDevPortForward();
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startDevPortForward(port);
|
||||||
|
const ready = await this.waitForElasticsearch(90_000);
|
||||||
|
if (ready) {
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.logger.log(`Elasticsearch reachable at 127.0.0.1:${port}`);
|
||||||
|
} else {
|
||||||
|
this.stopDevPortForward();
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not reach Elasticsearch on 127.0.0.1:${port}. Will retry. Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${port}:9200`,
|
||||||
|
);
|
||||||
|
this.schedulePortForwardReconnect('probe timeout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private localElasticsearchHint(): string {
|
||||||
|
const conn = this.getConnectionInfo();
|
||||||
|
if (this.isLoopbackHost(conn.host)) {
|
||||||
|
return (
|
||||||
|
`Ensure port ${conn.port} is forwarded to the cluster (the API auto-starts kubectl port-forward in development). ` +
|
||||||
|
`Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (conn.host.includes('svc.cluster.local') || conn.host.includes('.cluster.')) {
|
||||||
|
return (
|
||||||
|
'Run the API inside the cluster, or set ELASTICSEARCH_HOST=127.0.0.1 and keep port-forward running: ' +
|
||||||
|
`kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `Ensure Elasticsearch is listening on ${conn.host}:${conn.port}.`;
|
||||||
|
}
|
||||||
|
|
||||||
private async getK8sClients(clusterId?: string) {
|
private async getK8sClients(clusterId?: string) {
|
||||||
const cluster = clusterId
|
const cluster = clusterId
|
||||||
? await this.clustersService.findOne(clusterId)
|
? await this.clustersService.findOne(clusterId)
|
||||||
@@ -178,6 +441,10 @@ export class ElasticsearchService {
|
|||||||
elasticPassword: this.ELASTIC_PASSWORD,
|
elasticPassword: this.ELASTIC_PASSWORD,
|
||||||
fluentbitPassword: this.FLUENTBIT_PASSWORD,
|
fluentbitPassword: this.FLUENTBIT_PASSWORD,
|
||||||
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
|
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
|
||||||
|
images: {
|
||||||
|
elasticsearch: this.configService.get<string>('elasticsearch.images.elasticsearch'),
|
||||||
|
kibana: this.configService.get<string>('elasticsearch.images.kibana'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -214,8 +481,10 @@ export class ElasticsearchService {
|
|||||||
*/
|
*/
|
||||||
getConnectionInfo(): { host: string; port: number; username: string; password: string } {
|
getConnectionInfo(): { host: string; port: number; username: string; password: string } {
|
||||||
return {
|
return {
|
||||||
host: `${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`,
|
host:
|
||||||
port: 9200,
|
this.configService.get<string>('elasticsearch.host') ||
|
||||||
|
`${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`,
|
||||||
|
port: this.configService.get<number>('elasticsearch.port') || 9200,
|
||||||
username: 'elastic',
|
username: 'elastic',
|
||||||
password: this.ELASTIC_PASSWORD,
|
password: this.ELASTIC_PASSWORD,
|
||||||
};
|
};
|
||||||
@@ -363,6 +632,18 @@ export class ElasticsearchService {
|
|||||||
return `logs-user-${userId.split('-')[0]}-*`;
|
return `logs-user-${userId.split('-')[0]}-*`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private elasticsearchFetch(url: string, auth: string, body: unknown): Promise<Response> {
|
||||||
|
return fetch(url, {
|
||||||
|
method: body === undefined ? 'GET' : 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Basic ${auth}`,
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> {
|
private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> {
|
||||||
const deployed = await this.isDeployed(clusterId);
|
const deployed = await this.isDeployed(clusterId);
|
||||||
if (!deployed) {
|
if (!deployed) {
|
||||||
@@ -375,14 +656,23 @@ export class ElasticsearchService {
|
|||||||
const url = `http://${conn.host}:${conn.port}${path}`;
|
const url = `http://${conn.host}:${conn.port}${path}`;
|
||||||
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
|
const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64');
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let response: Response | undefined;
|
||||||
method: body === undefined ? 'GET' : 'POST',
|
try {
|
||||||
headers: {
|
response = await this.elasticsearchFetch(url, auth, body);
|
||||||
'Content-Type': 'application/json',
|
} catch (err: any) {
|
||||||
Authorization: `Basic ${auth}`,
|
if (this.shouldAutoPortForward()) {
|
||||||
},
|
await this.ensureLocalElasticsearchAccess();
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
try {
|
||||||
});
|
response = await this.elasticsearchFetch(url, auth, body);
|
||||||
|
} catch {
|
||||||
|
// retry failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response) {
|
||||||
|
this.logger.warn(`Elasticsearch unreachable at ${conn.host}:${conn.port}: ${err?.message || err}`);
|
||||||
|
throw new ServiceUnavailableException(`Cannot reach Elasticsearch. ${this.localElasticsearchHint()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -518,8 +808,36 @@ export class ElasticsearchService {
|
|||||||
return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h));
|
return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLoggingStatus(clusterId?: string): Promise<{ available: boolean; deployed: boolean }> {
|
async getLoggingStatus(
|
||||||
const deployed = await this.isDeployed(clusterId);
|
clusterId?: string,
|
||||||
return { available: deployed, deployed };
|
): Promise<{ available: boolean; deployed: boolean; recovering?: boolean; message?: string }> {
|
||||||
|
let deployed = await this.isDeployed(clusterId);
|
||||||
|
if (!deployed && this.shouldAutoPortForward()) {
|
||||||
|
deployed = await this.waitForLoggingStack(8_000);
|
||||||
|
}
|
||||||
|
if (!deployed) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
deployed: false,
|
||||||
|
message: 'Central logging is not deployed. Ask an administrator to deploy Elasticsearch.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldAutoPortForward() && !(await this.probeElasticsearch())) {
|
||||||
|
void this.ensureLocalElasticsearchAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.probeElasticsearch()) {
|
||||||
|
return { available: true, deployed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
deployed: true,
|
||||||
|
recovering: this.shouldAutoPortForward(),
|
||||||
|
message: this.shouldAutoPortForward()
|
||||||
|
? 'Reconnecting to Elasticsearch after cluster or API restart. This usually takes under a minute.'
|
||||||
|
: `Elasticsearch is running in the cluster, but this backend cannot reach it. ${this.localElasticsearchHint()}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ export class HelmService {
|
|||||||
*/
|
*/
|
||||||
async installLoggingStack(
|
async installLoggingStack(
|
||||||
kubeconfig: string,
|
kubeconfig: string,
|
||||||
values: { elasticPassword: string; fluentbitPassword: string; kibanaSystemPassword: string },
|
values: {
|
||||||
|
elasticPassword: string;
|
||||||
|
fluentbitPassword: string;
|
||||||
|
kibanaSystemPassword: string;
|
||||||
|
images?: { elasticsearch?: string; kibana?: string };
|
||||||
|
},
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
return this.installOrUpgradeFromChart(
|
return this.installOrUpgradeFromChart(
|
||||||
'cloudhost-logging',
|
'cloudhost-logging',
|
||||||
@@ -110,6 +115,16 @@ export class HelmService {
|
|||||||
elasticPassword: values.elasticPassword,
|
elasticPassword: values.elasticPassword,
|
||||||
fluentbitPassword: values.fluentbitPassword,
|
fluentbitPassword: values.fluentbitPassword,
|
||||||
kibanaSystemPassword: values.kibanaSystemPassword,
|
kibanaSystemPassword: values.kibanaSystemPassword,
|
||||||
|
...(values.images?.elasticsearch || values.images?.kibana
|
||||||
|
? {
|
||||||
|
images: {
|
||||||
|
...(values.images.elasticsearch
|
||||||
|
? { elasticsearch: values.images.elasticsearch }
|
||||||
|
: {}),
|
||||||
|
...(values.images.kibana ? { kibana: values.images.kibana } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
kubeconfig,
|
kubeconfig,
|
||||||
{ wait: true, timeout: '10m' },
|
{ wait: true, timeout: '10m' },
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
logPaths: app.logPaths || [],
|
logPaths: app.logPaths || [],
|
||||||
ownerId: app.userId,
|
ownerId: app.userId,
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
|
elasticPassword: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure',
|
||||||
|
fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer',
|
||||||
|
kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System',
|
||||||
},
|
},
|
||||||
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
|
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
|
||||||
};
|
};
|
||||||
@@ -602,8 +605,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
manifests.rabbitmq = true;
|
manifests.rabbitmq = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3.7 Create Fluent Bit ConfigMap if Elasticsearch is enabled
|
// 3.7 Logging: credentials secret + Fluent Bit config
|
||||||
if (context.enableElasticsearch) {
|
if (context.enableElasticsearch) {
|
||||||
|
await this.ensureElasticsearchCredentialsSecret(coreApi, context.namespace);
|
||||||
await this.createFluentBitConfigMap(coreApi, context);
|
await this.createFluentBitConfigMap(coreApi, context);
|
||||||
manifests.fluentBitConfig = true;
|
manifests.fluentBitConfig = true;
|
||||||
}
|
}
|
||||||
@@ -874,6 +878,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
// Add log volume mount if Elasticsearch is enabled
|
// Add log volume mount if Elasticsearch is enabled
|
||||||
if (ctx.enableElasticsearch) {
|
if (ctx.enableElasticsearch) {
|
||||||
appContainer.volumeMounts.push({ name: 'app-logs', mountPath: '/var/log/app' });
|
appContainer.volumeMounts.push({ name: 'app-logs', mountPath: '/var/log/app' });
|
||||||
|
this.applyLoggingCommandWrapper(appContainer, ctx.runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
containers.push(appContainer);
|
containers.push(appContainer);
|
||||||
@@ -943,21 +948,97 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
return ['/var/www/html/storage/logs/*.log', '/var/log/app/*.log'];
|
return ['/var/www/html/storage/logs/*.log', '/var/log/app/*.log'];
|
||||||
case AppRuntime.PHP:
|
case AppRuntime.PHP:
|
||||||
return ['/var/www/html/storage/logs/*.log', '/var/log/php/*.log', '/var/log/app/*.log'];
|
return ['/var/www/html/storage/logs/*.log', '/var/log/php/*.log', '/var/log/app/*.log'];
|
||||||
case AppRuntime.DJANGO:
|
|
||||||
return ['/app/logs/*.log', '/var/log/app/*.log'];
|
|
||||||
case AppRuntime.PYTHON:
|
|
||||||
return ['/app/logs/*.log', '/var/log/app/*.log'];
|
|
||||||
case AppRuntime.NODEJS:
|
|
||||||
return ['/app/logs/*.log', '/var/log/app/*.log'];
|
|
||||||
case AppRuntime.GO:
|
|
||||||
return ['/app/logs/*.log', '/var/log/app/*.log'];
|
|
||||||
case AppRuntime.DOTNET:
|
|
||||||
return ['/app/logs/*.log', '/var/log/app/*.log'];
|
|
||||||
default:
|
default:
|
||||||
|
// Node/Go/Python/.NET log to stdout — captured into /var/log/app/app.log at runtime
|
||||||
return ['/var/log/app/*.log'];
|
return ['/var/log/app/*.log'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect stdout/stderr into the shared log volume so Fluent Bit can tail them.
|
||||||
|
*/
|
||||||
|
private applyLoggingCommandWrapper(container: any, runtime: string): void {
|
||||||
|
const startCmd = this.getRuntimeStartCommand(runtime);
|
||||||
|
if (!startCmd) return;
|
||||||
|
container.command = ['sh', '-c'];
|
||||||
|
container.args = [`mkdir -p /var/log/app && (${startCmd}) >> /var/log/app/app.log 2>&1`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shell command that mirrors CloudHost-generated image ENTRYPOINT/CMD per runtime. */
|
||||||
|
private getRuntimeStartCommand(runtime: string): string | null {
|
||||||
|
switch (runtime) {
|
||||||
|
case AppRuntime.NODEJS:
|
||||||
|
return (
|
||||||
|
'if [ -f /app/.mode ] && [ "$(cat /app/.mode)" = "standalone" ] && [ -f server.js ]; ' +
|
||||||
|
'then node server.js; else npm start; fi'
|
||||||
|
);
|
||||||
|
case AppRuntime.GO:
|
||||||
|
return './main';
|
||||||
|
case AppRuntime.PYTHON:
|
||||||
|
return (
|
||||||
|
'if [ -f main.py ]; then ' +
|
||||||
|
'if grep -qi fastapi main.py; then exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-3000}; ' +
|
||||||
|
'elif grep -qi flask main.py; then exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} main:app; ' +
|
||||||
|
'else exec python main.py; fi; ' +
|
||||||
|
'elif [ -f app.py ]; then ' +
|
||||||
|
'if grep -qi fastapi app.py; then exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-3000}; ' +
|
||||||
|
'elif grep -qi flask app.py; then exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} app:app; ' +
|
||||||
|
'else exec python app.py; fi; ' +
|
||||||
|
'else exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} app:app; fi'
|
||||||
|
);
|
||||||
|
case AppRuntime.DJANGO:
|
||||||
|
return 'python manage.py runserver 0.0.0.0:${PORT:-8000}';
|
||||||
|
case AppRuntime.DOTNET:
|
||||||
|
return (
|
||||||
|
'DLL=$(find . -maxdepth 1 -name "*.dll" ! -name "*.deps.dll" ! -name "*.runtimeconfig.dll" | head -1) ' +
|
||||||
|
'&& dotnet "$DLL"'
|
||||||
|
);
|
||||||
|
case AppRuntime.WORDPRESS:
|
||||||
|
case AppRuntime.LARAVEL:
|
||||||
|
case AppRuntime.PHP:
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */
|
||||||
|
private async ensureElasticsearchCredentialsSecret(
|
||||||
|
coreApi: k8s.CoreV1Api,
|
||||||
|
namespace: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const name = 'elasticsearch-credentials';
|
||||||
|
const stringData = {
|
||||||
|
ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure',
|
||||||
|
FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer',
|
||||||
|
KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.readNamespacedSecret(name, namespace);
|
||||||
|
await coreApi.replaceNamespacedSecret(name, namespace, {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: { name, namespace },
|
||||||
|
type: 'Opaque',
|
||||||
|
stringData,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||||
|
await coreApi.createNamespacedSecret(namespace, {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: { name, namespace },
|
||||||
|
type: 'Opaque',
|
||||||
|
stringData,
|
||||||
|
});
|
||||||
|
this.logger.log(`Created ${name} secret in ${namespace}`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Fluent Bit configuration for log collection
|
* Build Fluent Bit configuration for log collection
|
||||||
*/
|
*/
|
||||||
@@ -3755,6 +3836,56 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
return bytes / (1024 * 1024 * 1024);
|
return bytes / (1024 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
|
||||||
|
*/
|
||||||
|
async resizeNamedPvc(
|
||||||
|
app: Application,
|
||||||
|
pvcName: string,
|
||||||
|
newSize: string,
|
||||||
|
label: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
||||||
|
const currentSize = pvc.body.spec?.resources?.requests?.storage || '1Gi';
|
||||||
|
const parseGi = (s: string) => parseInt(String(s).replace(/Gi/i, ''), 10) || 0;
|
||||||
|
|
||||||
|
if (parseGi(newSize) <= parseGi(currentSize)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `New size (${newSize}) must be larger than current size (${currentSize})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.patchPvcStorageSize(coreApi, pvcName, namespace, newSize);
|
||||||
|
this.logger.log(`Expanded ${pvcName} from ${currentSize} to ${newSize}`);
|
||||||
|
return { success: true, message: `${label} storage expanded from ${currentSize} to ${newSize}` };
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`Failed to resize ${pvcName}: ${e.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: e.body?.message || e.message || `Failed to resize ${label} storage`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizeRedisStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (!app.enableRedis) {
|
||||||
|
return { success: false, message: 'Redis is not enabled for this application' };
|
||||||
|
}
|
||||||
|
return this.resizeNamedPvc(app, `${app.name}-redis-data`, newSize, 'Redis');
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizeRabbitmqStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (!app.enableRabbitmq) {
|
||||||
|
return { success: false, message: 'RabbitMQ is not enabled for this application' };
|
||||||
|
}
|
||||||
|
return this.resizeNamedPvc(app, `${app.name}-rabbitmq-data`, newSize, 'RabbitMQ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resize app storage PVC (all app types).
|
* Resize app storage PVC (all app types).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ import { useAuthStore } from '@/lib/store';
|
|||||||
/** Matches backend multipart limit for POST /applications/:id/upload */
|
/** Matches backend multipart limit for POST /applications/:id/upload */
|
||||||
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
||||||
|
|
||||||
|
type UpgradePayload = {
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
replicas?: number;
|
||||||
|
dbStorageSize?: string;
|
||||||
|
appStorageSize?: string;
|
||||||
|
redisResources?: {
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
storageGi?: number;
|
||||||
|
};
|
||||||
|
rabbitmqResources?: {
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
storageGi?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
running: 'badge-green',
|
running: 'badge-green',
|
||||||
pending: 'badge-yellow',
|
pending: 'badge-yellow',
|
||||||
@@ -80,8 +104,9 @@ export default function AppDetailPage() {
|
|||||||
});
|
});
|
||||||
const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app');
|
const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app');
|
||||||
const [resourceFormDirty, setResourceFormDirty] = useState(false);
|
const [resourceFormDirty, setResourceFormDirty] = useState(false);
|
||||||
const [showDbDiskExpand, setShowDbDiskExpand] = useState(false);
|
|
||||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||||
|
const [redisStorageSize, setRedisStorageSize] = useState('1');
|
||||||
|
const [rabbitmqStorageSize, setRabbitmqStorageSize] = useState('2');
|
||||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||||
const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null);
|
const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null);
|
||||||
@@ -95,6 +120,7 @@ export default function AppDetailPage() {
|
|||||||
currentCost: { hourly: number };
|
currentCost: { hourly: number };
|
||||||
newCost: { hourly: number };
|
newCost: { hourly: number };
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [pendingUpgradePayload, setPendingUpgradePayload] = useState<UpgradePayload | null>(null);
|
||||||
|
|
||||||
// ── Custom Domain ──────────────────────────────────
|
// ── Custom Domain ──────────────────────────────────
|
||||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||||
@@ -192,8 +218,6 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
// App storage expansion state
|
// App storage expansion state
|
||||||
const [appStorageSize, setAppStorageSize] = useState('2');
|
const [appStorageSize, setAppStorageSize] = useState('2');
|
||||||
const [showAppStorageExpand, setShowAppStorageExpand] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (app?.appStorageSize) {
|
if (app?.appStorageSize) {
|
||||||
const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2;
|
const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2;
|
||||||
@@ -201,38 +225,17 @@ export default function AppDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [app?.appStorageSize]);
|
}, [app?.appStorageSize]);
|
||||||
|
|
||||||
const resizeAppStorageMutation = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/app-storage`, { size }),
|
if (storageUsage?.redisStorage) {
|
||||||
onSuccess: (res) => {
|
setRedisStorageSize(String(Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi))));
|
||||||
if (res.data.success) {
|
}
|
||||||
toast.success(res.data.message || 'App storage expanded!');
|
}, [storageUsage?.redisStorage?.allocatedGi]);
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
|
||||||
setShowAppStorageExpand(false);
|
|
||||||
} else {
|
|
||||||
toast.error(res.data.message || 'Failed to expand storage');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(err.response?.data?.message || 'Failed to resize app storage');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resizeDbMutation = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }),
|
if (storageUsage?.rabbitmqStorage) {
|
||||||
onSuccess: (res) => {
|
setRabbitmqStorageSize(String(Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi))));
|
||||||
if (res.data.success) {
|
}
|
||||||
toast.success(res.data.message || 'Database storage expanded!');
|
}, [storageUsage?.rabbitmqStorage?.allocatedGi]);
|
||||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
|
||||||
} else {
|
|
||||||
toast.error(res.data.message || 'Failed to expand storage');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(err.response?.data?.message || 'Failed to resize database storage');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Billing & Renewal ──────────────────────────────
|
// ─── Billing & Renewal ──────────────────────────────
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
@@ -599,15 +602,17 @@ export default function AppDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scaleMutation = useMutation({
|
const scaleMutation = useMutation({
|
||||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade`, data),
|
||||||
api.post(`/billing/applications/${appId}/upgrade`, data),
|
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setResourceFormDirty(false);
|
setResourceFormDirty(false);
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
|
setPendingUpgradePayload(null);
|
||||||
const paidAmount = res.data.paidAmount || 0;
|
const paidAmount = res.data.paidAmount || 0;
|
||||||
if (paidAmount > 0) {
|
if (paidAmount > 0) {
|
||||||
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
|
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
|
||||||
@@ -638,8 +643,7 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
// Calculate upgrade cost before applying
|
// Calculate upgrade cost before applying
|
||||||
const calculateUpgradeCostMutation = useMutation({
|
const calculateUpgradeCostMutation = useMutation({
|
||||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/calculate`, data),
|
||||||
api.post(`/billing/applications/${appId}/upgrade/calculate`, data),
|
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setUpgradeCostData(res.data);
|
setUpgradeCostData(res.data);
|
||||||
setShowUpgradeConfirm(true);
|
setShowUpgradeConfirm(true);
|
||||||
@@ -648,35 +652,201 @@ export default function AppDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createUpgradeInvoiceMutation = useMutation({
|
const createUpgradeInvoiceMutation = useMutation({
|
||||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
mutationFn: (data: UpgradePayload) =>
|
||||||
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
|
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
|
||||||
onSuccess: (invoice) => {
|
onSuccess: (invoice) => {
|
||||||
toast.success('Invoice created. Choose how you want to pay.');
|
toast.success('Invoice created. Choose how you want to pay.');
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
|
setPendingUpgradePayload(null);
|
||||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
|
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
|
const buildWorkloadUpgradePayload = useCallback((): UpgradePayload => {
|
||||||
|
if (!app) return {};
|
||||||
|
switch (scaleWorkload) {
|
||||||
|
case 'app': {
|
||||||
|
const payload: UpgradePayload = {
|
||||||
|
cpuRequest: resourceForm.cpuRequest || undefined,
|
||||||
|
cpuLimit: resourceForm.cpuLimit || undefined,
|
||||||
|
memoryRequest: resourceForm.memoryRequest || undefined,
|
||||||
|
memoryLimit: resourceForm.memoryLimit || undefined,
|
||||||
|
replicas: resourceForm.replicas,
|
||||||
|
};
|
||||||
|
const minAppGi = parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
|
||||||
|
const newAppGi = parseInt(appStorageSize, 10);
|
||||||
|
if (newAppGi > minAppGi) payload.appStorageSize = `${newAppGi}Gi`;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
case 'database': {
|
||||||
|
const minDbGi =
|
||||||
|
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||||
|
const newDbGi = parseInt(dbStorageSize, 10);
|
||||||
|
if (newDbGi > minDbGi) return { dbStorageSize: `${newDbGi}Gi` };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case 'redis': {
|
||||||
|
const prev = app.optionalServiceResources?.redis;
|
||||||
|
const minGi = Math.max(1, Math.round(storageUsage?.redisStorage?.allocatedGi ?? prev?.storageGi ?? 1));
|
||||||
|
const newGi = parseInt(redisStorageSize, 10);
|
||||||
|
return {
|
||||||
|
redisResources: {
|
||||||
|
cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest,
|
||||||
|
cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit,
|
||||||
|
memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest,
|
||||||
|
memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit,
|
||||||
|
storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'rabbitmq': {
|
||||||
|
const prev = app.optionalServiceResources?.rabbitmq;
|
||||||
|
const minGi = Math.max(2, Math.round(storageUsage?.rabbitmqStorage?.allocatedGi ?? prev?.storageGi ?? 2));
|
||||||
|
const newGi = parseInt(rabbitmqStorageSize, 10);
|
||||||
|
return {
|
||||||
|
rabbitmqResources: {
|
||||||
|
cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest,
|
||||||
|
cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit,
|
||||||
|
memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest,
|
||||||
|
memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit,
|
||||||
|
storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
app,
|
||||||
|
scaleWorkload,
|
||||||
|
resourceForm,
|
||||||
|
appStorageSize,
|
||||||
|
dbStorageSize,
|
||||||
|
redisStorageSize,
|
||||||
|
rabbitmqStorageSize,
|
||||||
|
dbStorageData?.currentSize,
|
||||||
|
storageUsage?.redisStorage?.allocatedGi,
|
||||||
|
storageUsage?.rabbitmqStorage?.allocatedGi,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const patchDatabaseCpuIfNeeded = () => {
|
||||||
|
if (scaleWorkload !== 'database') return;
|
||||||
|
directPatchResourcesMutation.mutate({
|
||||||
|
workload: 'database',
|
||||||
|
cpuRequest: resourceForm.cpuRequest || undefined,
|
||||||
|
cpuLimit: resourceForm.cpuLimit || undefined,
|
||||||
|
memoryRequest: resourceForm.memoryRequest || undefined,
|
||||||
|
memoryLimit: resourceForm.memoryLimit || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleScaleResources = () => {
|
const handleScaleResources = () => {
|
||||||
if (scaleWorkload !== 'app') {
|
if (!app) return;
|
||||||
directPatchResourcesMutation.mutate({
|
if (app.lifecycleStatus && app.lifecycleStatus !== 'active') {
|
||||||
workload: scaleWorkload,
|
if (scaleWorkload === 'database') {
|
||||||
cpuRequest: resourceForm.cpuRequest || undefined,
|
patchDatabaseCpuIfNeeded();
|
||||||
cpuLimit: resourceForm.cpuLimit || undefined,
|
return;
|
||||||
memoryRequest: resourceForm.memoryRequest || undefined,
|
}
|
||||||
memoryLimit: resourceForm.memoryLimit || undefined,
|
toast.warn('Renew the application before changing resources');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildWorkloadUpgradePayload();
|
||||||
|
const hasBillingPayload = Object.keys(payload).length > 0;
|
||||||
|
const needsDbCpuPatch = scaleWorkload === 'database';
|
||||||
|
|
||||||
|
if (!hasBillingPayload && !needsDbCpuPatch) {
|
||||||
|
toast.warn('No changes to apply');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.billingCycle) {
|
||||||
|
if (hasBillingPayload) {
|
||||||
|
scaleMutation.mutate(payload, {
|
||||||
|
onSuccess: () => patchDatabaseCpuIfNeeded(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
patchDatabaseCpuIfNeeded();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBillingPayload) {
|
||||||
|
patchDatabaseCpuIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingUpgradePayload(payload);
|
||||||
|
calculateUpgradeCostMutation.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUpgradeApply = () => {
|
||||||
|
if (!pendingUpgradePayload || !upgradeCostData) return;
|
||||||
|
if (upgradeCostData.proratedAmount > 0) {
|
||||||
|
createUpgradeInvoiceMutation.mutate(pendingUpgradePayload);
|
||||||
|
} else {
|
||||||
|
scaleMutation.mutate(pendingUpgradePayload, {
|
||||||
|
onSuccess: () => patchDatabaseCpuIfNeeded(),
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!app?.billingCycle) {
|
};
|
||||||
scaleMutation.mutate(resourceForm);
|
|
||||||
return;
|
const workloadStorageConfig = (): {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
setValue: (v: string) => void;
|
||||||
|
minGi: number;
|
||||||
|
maxGi: number;
|
||||||
|
currentGi: number;
|
||||||
|
} | null => {
|
||||||
|
if (!app) return null;
|
||||||
|
switch (scaleWorkload) {
|
||||||
|
case 'app':
|
||||||
|
if (!storageUsage?.appStorage) return null;
|
||||||
|
return {
|
||||||
|
label: 'Application volume',
|
||||||
|
value: appStorageSize,
|
||||||
|
setValue: setAppStorageSize,
|
||||||
|
minGi: parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2,
|
||||||
|
maxGi: 100,
|
||||||
|
currentGi: storageUsage.appStorage.allocatedGi,
|
||||||
|
};
|
||||||
|
case 'database':
|
||||||
|
if (app.databaseType === 'none' || !storageUsage?.database) return null;
|
||||||
|
return {
|
||||||
|
label: 'Database volume',
|
||||||
|
value: dbStorageSize,
|
||||||
|
setValue: setDbStorageSize,
|
||||||
|
minGi: parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1,
|
||||||
|
maxGi: 500,
|
||||||
|
currentGi: storageUsage.database.allocatedGi,
|
||||||
|
};
|
||||||
|
case 'redis':
|
||||||
|
if (!app.enableRedis || !storageUsage?.redisStorage) return null;
|
||||||
|
return {
|
||||||
|
label: 'Redis volume',
|
||||||
|
value: redisStorageSize,
|
||||||
|
setValue: setRedisStorageSize,
|
||||||
|
minGi: Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi)),
|
||||||
|
maxGi: 100,
|
||||||
|
currentGi: storageUsage.redisStorage.allocatedGi,
|
||||||
|
};
|
||||||
|
case 'rabbitmq':
|
||||||
|
if (!app.enableRabbitmq || !storageUsage?.rabbitmqStorage) return null;
|
||||||
|
return {
|
||||||
|
label: 'RabbitMQ volume',
|
||||||
|
value: rabbitmqStorageSize,
|
||||||
|
setValue: setRabbitmqStorageSize,
|
||||||
|
minGi: Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi)),
|
||||||
|
maxGi: 100,
|
||||||
|
currentGi: storageUsage.rabbitmqStorage.allocatedGi,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
calculateUpgradeCostMutation.mutate(resourceForm);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
@@ -1187,19 +1357,14 @@ export default function AppDetailPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
|
setPendingUpgradePayload(null);
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
|
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={confirmUpgradeApply}
|
||||||
if (upgradeCostData.proratedAmount > 0) {
|
|
||||||
createUpgradeInvoiceMutation.mutate(resourceForm);
|
|
||||||
} else {
|
|
||||||
scaleMutation.mutate(resourceForm);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
|
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
|
||||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -1639,71 +1804,6 @@ export default function AppDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Database Storage Management */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Database Storage</h3>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xs text-gray-500">Current Size:</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-800">{dbStorageData?.currentSize || app.dbStorageSize || '1Gi'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(dbStorageSize, 10);
|
|
||||||
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
|
||||||
if (current > min + 1) setDbStorageSize(String(current - 1));
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={dbStorageSize}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
|
|
||||||
setDbStorageSize(String(val));
|
|
||||||
}}
|
|
||||||
className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(dbStorageSize, 10);
|
|
||||||
if (current < 100) setDbStorageSize(String(current + 1));
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-600">GB</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const newSize = `${parseInt(dbStorageSize, 10)}Gi`;
|
|
||||||
resizeDbMutation.mutate(newSize);
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
resizeDbMutation.isPending ||
|
|
||||||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
|
||||||
}
|
|
||||||
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed (shrinking is not possible)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DB Dump Upload */}
|
{/* DB Dump Upload */}
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Restore Database Dump</h3>
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Restore Database Dump</h3>
|
||||||
<div
|
<div
|
||||||
@@ -2055,70 +2155,7 @@ export default function AppDetailPage() {
|
|||||||
<span>Used {storageUsage.database.usedGi.toFixed(2)} GiB</span>
|
<span>Used {storageUsage.database.usedGi.toFixed(2)} GiB</span>
|
||||||
<span>Free ~{storageUsage.database.availableGi.toFixed(2)} GiB</span>
|
<span>Free ~{storageUsage.database.availableGi.toFixed(2)} GiB</span>
|
||||||
</div>
|
</div>
|
||||||
{app?.databaseType !== 'none' && (
|
<p className="text-[11px] text-gray-400 mt-2">Expand disk in Adjust CPU / memory & storage below.</p>
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
|
||||||
{showDbDiskExpand ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(dbStorageSize, 10);
|
|
||||||
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
|
||||||
if (current > min + 1) setDbStorageSize(String(current - 1));
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={500}
|
|
||||||
value={dbStorageSize}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Math.max(1, Math.min(500, parseInt(e.target.value, 10) || 1));
|
|
||||||
setDbStorageSize(String(val));
|
|
||||||
}}
|
|
||||||
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(dbStorageSize, 10);
|
|
||||||
if (current < 500) setDbStorageSize(String(current + 1));
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-600">GiB</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => resizeDbMutation.mutate(`${parseInt(dbStorageSize, 10)}Gi`)}
|
|
||||||
disabled={
|
|
||||||
resizeDbMutation.isPending ||
|
|
||||||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
|
||||||
}
|
|
||||||
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{resizeDbMutation.isPending ? 'Expanding…' : 'Expand DB disk'}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setShowDbDiskExpand(false)} className="btn-secondary text-xs px-2 py-1">Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowDbDiskExpand(true)}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Scale className="w-3 h-3" /> Expand database disk
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<p className="text-[11px] text-gray-400 mt-1">PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2148,79 +2185,7 @@ export default function AppDetailPage() {
|
|||||||
<span>Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB</span>
|
<span>Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB</span>
|
||||||
<span>Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB</span>
|
<span>Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-2">Expand disk in Adjust CPU / memory & storage below.</p>
|
||||||
{/* Expand App Storage (all app types) */}
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
|
||||||
{showAppStorageExpand ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(appStorageSize, 10);
|
|
||||||
const min = parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
|
|
||||||
if (current > min + 1) setAppStorageSize(String(current - 1));
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={2}
|
|
||||||
max={100}
|
|
||||||
value={appStorageSize}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Math.max(2, Math.min(100, parseInt(e.target.value, 10) || 2));
|
|
||||||
setAppStorageSize(String(val));
|
|
||||||
}}
|
|
||||||
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(appStorageSize, 10);
|
|
||||||
if (current < 100) setAppStorageSize(String(current + 1));
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-600">GB</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const newSize = `${parseInt(appStorageSize, 10)}Gi`;
|
|
||||||
resizeAppStorageMutation.mutate(newSize);
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
resizeAppStorageMutation.isPending ||
|
|
||||||
parseInt(appStorageSize, 10) <= (parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2)
|
|
||||||
}
|
|
||||||
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{resizeAppStorageMutation.isPending ? 'Expanding...' : 'Expand'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAppStorageExpand(false)}
|
|
||||||
className="btn-secondary text-xs px-2 py-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAppStorageExpand(true)}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Scale className="w-3 h-3" /> Expand Storage
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2238,7 +2203,9 @@ export default function AppDetailPage() {
|
|||||||
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
|
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.redisStorage.allocatedRaw}</p>
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
|
Allocated {storageUsage.redisStorage.allocatedRaw} — expand in Adjust CPU / memory & storage.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2256,7 +2223,9 @@ export default function AppDetailPage() {
|
|||||||
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
|
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.rabbitmqStorage.allocatedRaw}</p>
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
|
Allocated {storageUsage.rabbitmqStorage.allocatedRaw} — expand in Adjust CPU / memory & storage.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2271,9 +2240,12 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
{/* Scaling Controls */}
|
{/* Scaling Controls */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Adjust CPU / memory</h3>
|
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||||
|
<Settings className="w-4 h-4" /> Adjust CPU / memory & storage
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
Pick which component to update. The main application may use billing if your plan charges for upgrades; database and optional services apply directly in the cluster.
|
Pick which component to update. CPU and memory apply per workload; storage can only grow (expand). The main
|
||||||
|
application may use billing for paid upgrades; database and optional services apply directly in the cluster.
|
||||||
</p>
|
</p>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-xs text-gray-500 mb-1">Workload</label>
|
<label className="block text-xs text-gray-500 mb-1">Workload</label>
|
||||||
@@ -2351,6 +2323,65 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const storageCfg = workloadStorageConfig();
|
||||||
|
if (!storageCfg) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||||
|
Storage — {storageCfg.label}
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] text-gray-500 mb-2">
|
||||||
|
Current: {storageCfg.currentGi.toFixed(1)} GiB allocated (expand only, no shrink). Applied with
|
||||||
|
Apply changes.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const c = parseInt(storageCfg.value, 10);
|
||||||
|
if (c > storageCfg.minGi + 1) storageCfg.setValue(String(c - 1));
|
||||||
|
}}
|
||||||
|
className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={storageCfg.minGi + 1}
|
||||||
|
max={storageCfg.maxGi}
|
||||||
|
value={storageCfg.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Math.max(
|
||||||
|
storageCfg.minGi + 1,
|
||||||
|
Math.min(storageCfg.maxGi, parseInt(e.target.value, 10) || storageCfg.minGi + 1),
|
||||||
|
);
|
||||||
|
storageCfg.setValue(String(val));
|
||||||
|
}}
|
||||||
|
className="w-14 text-center py-1.5 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const c = parseInt(storageCfg.value, 10);
|
||||||
|
if (c < storageCfg.maxGi) storageCfg.setValue(String(c + 1));
|
||||||
|
}}
|
||||||
|
className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600">GiB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div className="sm:col-span-2" />
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -67,9 +67,25 @@ function LogsPageContent() {
|
|||||||
if (initialAppId) setAppId(initialAppId);
|
if (initialAppId) setAppId(initialAppId);
|
||||||
}, [initialAppId]);
|
}, [initialAppId]);
|
||||||
|
|
||||||
const { data: loggingStatus } = useQuery({
|
const { data: loggingStatus, isFetching: statusFetching } = useQuery({
|
||||||
queryKey: ['logs-status'],
|
queryKey: ['logs-status'],
|
||||||
queryFn: () => api.get('/logs/status').then((r) => r.data as { available: boolean }),
|
queryFn: () =>
|
||||||
|
api
|
||||||
|
.get('/logs/status')
|
||||||
|
.then((r) =>
|
||||||
|
r.data as {
|
||||||
|
available: boolean;
|
||||||
|
deployed?: boolean;
|
||||||
|
recovering?: boolean;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const s = query.state.data;
|
||||||
|
if (s?.available) return false;
|
||||||
|
if (s?.deployed === false) return false;
|
||||||
|
return 8_000;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: applications = [] } = useQuery<Application[]>({
|
const { data: applications = [] } = useQuery<Application[]>({
|
||||||
@@ -150,7 +166,7 @@ function LogsPageContent() {
|
|||||||
params.set('limit', '100');
|
params.set('limit', '100');
|
||||||
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
|
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
|
||||||
},
|
},
|
||||||
enabled: loggingStatus?.available !== false,
|
enabled: loggingStatus?.available === true,
|
||||||
refetchInterval: autoRefresh ? 5000 : false,
|
refetchInterval: autoRefresh ? 5000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,20 +179,32 @@ function LogsPageContent() {
|
|||||||
params.set('period', timeRange);
|
params.set('period', timeRange);
|
||||||
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
|
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
|
||||||
},
|
},
|
||||||
enabled: loggingStatus?.available !== false,
|
enabled: loggingStatus?.available === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
|
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
|
||||||
|
|
||||||
if (loggingStatus && !loggingStatus.available) {
|
if (loggingStatus && !loggingStatus.available) {
|
||||||
|
const isRecovering = loggingStatus.recovering || loggingStatus.deployed === true;
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto card p-8 text-center">
|
<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" />
|
{isRecovering ? (
|
||||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Logging not available</h1>
|
<Loader2 className="w-12 h-12 text-primary-500 mx-auto mb-4 animate-spin" />
|
||||||
<p className="text-gray-600 text-sm">
|
) : (
|
||||||
Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app,
|
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||||
and ask an administrator to deploy the logging stack.
|
)}
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">
|
||||||
|
{isRecovering ? 'Reconnecting to logging…' : 'Logging not available'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-sm whitespace-pre-wrap">
|
||||||
|
{loggingStatus.message ||
|
||||||
|
'Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app, and ask an administrator to deploy the logging stack.'}
|
||||||
</p>
|
</p>
|
||||||
|
{isRecovering && (
|
||||||
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
|
{statusFetching ? 'Checking connection…' : 'Retrying automatically every few seconds.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function DeletingTableRowCell({
|
|||||||
message?: string;
|
message?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<td colSpan={colSpan} className="px-6 py-4 bg-white/95" aria-live="polite" aria-busy="true">
|
<td colSpan={colSpan} className="px-6 py-4 bg-white/80 backdrop-blur-sm" aria-live="polite" aria-busy="true">
|
||||||
<div className="flex min-h-[52px] w-full items-center justify-center gap-2 text-sm font-semibold text-gray-800">
|
<div className="flex min-h-[52px] w-full items-center justify-center gap-2 text-sm font-semibold text-gray-800">
|
||||||
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
@@ -34,7 +34,7 @@ export function DeletingTableRowCell({
|
|||||||
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
|
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/92 backdrop-blur-[2px] text-sm font-semibold text-gray-800"
|
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/75 backdrop-blur-sm text-sm font-semibold text-gray-800"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
import { useQuery, useQueries, keepPreviousData } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Application } from '@/types';
|
import type { Application } from '@/types';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
@@ -49,16 +49,15 @@ export function DeploymentProgressManager() {
|
|||||||
.get<{ progress: BuildProgress | null }>(`/deployments/applications/${app.id}/build-progress`)
|
.get<{ progress: BuildProgress | null }>(`/deployments/applications/${app.id}/build-progress`)
|
||||||
.then((r) => r.data.progress),
|
.then((r) => r.data.progress),
|
||||||
refetchInterval: 1500,
|
refetchInterval: 1500,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeItems = useMemo(() => {
|
const activeItems = useMemo(() => {
|
||||||
return deployingApps
|
return deployingApps.map((app, i) => ({
|
||||||
.map((app, i) => ({
|
app,
|
||||||
app,
|
progress: progressQueries[i]?.data ?? null,
|
||||||
progress: progressQueries[i]?.data ?? null,
|
}));
|
||||||
}))
|
|
||||||
.filter(({ progress }) => isActiveBuildProgress(progress));
|
|
||||||
}, [deployingApps, progressQueries]);
|
}, [deployingApps, progressQueries]);
|
||||||
|
|
||||||
const routeAppId =
|
const routeAppId =
|
||||||
@@ -66,10 +65,10 @@ export function DeploymentProgressManager() {
|
|||||||
pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1];
|
pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeItems.length === 0) {
|
if (deployingApps.length === 0) {
|
||||||
useDeployProgressStore.setState({ minimized: false, focusedAppId: null });
|
useDeployProgressStore.setState({ minimized: false, focusedAppId: null });
|
||||||
}
|
}
|
||||||
}, [activeItems.length]);
|
}, [deployingApps.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeItems.length === 0) return;
|
if (activeItems.length === 0) return;
|
||||||
@@ -84,12 +83,15 @@ export function DeploymentProgressManager() {
|
|||||||
const focusedItem =
|
const focusedItem =
|
||||||
activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0];
|
activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0];
|
||||||
|
|
||||||
const focusedProgress = focusedItem?.progress;
|
const focusedProgress =
|
||||||
|
focusedItem?.progress ??
|
||||||
|
({ phase: 'building', percent: 0, message: 'Loading progress…' } satisfies BuildProgress);
|
||||||
const showModal =
|
const showModal =
|
||||||
!minimized &&
|
!minimized &&
|
||||||
!!focusedItem &&
|
!!focusedItem &&
|
||||||
!!focusedProgress &&
|
(!focusedItem.progress ||
|
||||||
isActiveBuildProgress(focusedProgress);
|
isActiveBuildProgress(focusedItem.progress) ||
|
||||||
|
focusedItem.progress.phase === 'done');
|
||||||
|
|
||||||
const handleMinimize = () => {
|
const handleMinimize = () => {
|
||||||
if (focusedItem) minimize(focusedItem.app.id);
|
if (focusedItem) minimize(focusedItem.app.id);
|
||||||
@@ -99,7 +101,7 @@ export function DeploymentProgressManager() {
|
|||||||
expand(appId);
|
expand(appId);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeItems.length === 0) return null;
|
if (deployingApps.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -109,7 +111,7 @@ export function DeploymentProgressManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showModal && focusedProgress && (
|
{showModal && (
|
||||||
<BuildProgressModal
|
<BuildProgressModal
|
||||||
appId={focusedItem.app.id}
|
appId={focusedItem.app.id}
|
||||||
appName={focusedItem.app.name}
|
appName={focusedItem.app.name}
|
||||||
|
|||||||
@@ -226,23 +226,6 @@ export function ManagedServiceResourcesPanel({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resizeDbMutation = useMutation({
|
|
||||||
mutationFn: (size: string) => api.patch(`/applications/${serviceId}/db-storage`, { size }),
|
|
||||||
onSuccess: (res) => {
|
|
||||||
if (res.data.success) {
|
|
||||||
toast.success(res.data.message || 'Storage expanded');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
|
|
||||||
} else {
|
|
||||||
toast.error(res.data.message || 'Failed to expand storage');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
||||||
toast.error(msg || 'Failed to resize storage');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const dbUploadMutation = useMutation({
|
const dbUploadMutation = useMutation({
|
||||||
mutationFn: (file: File) => {
|
mutationFn: (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -280,9 +263,15 @@ export function ManagedServiceResourcesPanel({
|
|||||||
[dbUploadMutation],
|
[dbUploadMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentDbGi =
|
||||||
|
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||||
|
|
||||||
const buildUpgradePayload = useCallback((): UpgradePayload => {
|
const buildUpgradePayload = useCallback((): UpgradePayload => {
|
||||||
if (app.productType === 'managed_database') {
|
if (app.productType === 'managed_database') {
|
||||||
return { ...dbResources };
|
const payload: UpgradePayload = { ...dbResources };
|
||||||
|
const newGi = parseInt(dbStorageSize, 10);
|
||||||
|
if (newGi > currentDbGi) payload.dbStorageSize = `${newGi}Gi`;
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
if (app.productType === 'managed_redis') {
|
if (app.productType === 'managed_redis') {
|
||||||
return {
|
return {
|
||||||
@@ -304,7 +293,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2,
|
storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [app, dbResources, redisResources, rabbitResources]);
|
}, [app, dbResources, redisResources, rabbitResources, dbStorageSize, currentDbGi]);
|
||||||
|
|
||||||
const applyResources = () => {
|
const applyResources = () => {
|
||||||
if (needsRenewal) {
|
if (needsRenewal) {
|
||||||
@@ -316,6 +305,10 @@ export function ManagedServiceResourcesPanel({
|
|||||||
const w = workloadKey(app);
|
const w = workloadKey(app);
|
||||||
|
|
||||||
if (!app.billingCycle) {
|
if (!app.billingCycle) {
|
||||||
|
if (Object.keys(payload).length > 0 && (payload.dbStorageSize || payload.redisResources || payload.rabbitmqResources)) {
|
||||||
|
scaleMutation.mutate(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (w === 'database') {
|
if (w === 'database') {
|
||||||
directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources });
|
directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources });
|
||||||
} else if (w === 'redis') {
|
} else if (w === 'redis') {
|
||||||
@@ -351,37 +344,12 @@ export function ManagedServiceResourcesPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExpandStorage = () => {
|
|
||||||
if (needsRenewal) {
|
|
||||||
toast.warn('Renew the service before expanding storage');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newGi = parseInt(dbStorageSize, 10);
|
|
||||||
if (newGi <= currentDbGi) {
|
|
||||||
toast.warn('New size must be larger than current allocation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newSize = `${newGi}Gi`;
|
|
||||||
const payload: UpgradePayload = { dbStorageSize: newSize };
|
|
||||||
|
|
||||||
if (!app.billingCycle) {
|
|
||||||
resizeDbMutation.mutate(newSize);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingUpgradePayload(payload);
|
|
||||||
calculateUpgradeCostMutation.mutate(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resourcesPending =
|
const resourcesPending =
|
||||||
directPatchResourcesMutation.isPending ||
|
directPatchResourcesMutation.isPending ||
|
||||||
scaleMutation.isPending ||
|
scaleMutation.isPending ||
|
||||||
calculateUpgradeCostMutation.isPending ||
|
calculateUpgradeCostMutation.isPending ||
|
||||||
createUpgradeInvoiceMutation.isPending;
|
createUpgradeInvoiceMutation.isPending;
|
||||||
|
|
||||||
const currentDbGi =
|
|
||||||
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
|
|
||||||
|
|
||||||
const metricsWorkload =
|
const metricsWorkload =
|
||||||
resourceUsage?.workloads?.find((w) => w.key === workloadKey(app)) ||
|
resourceUsage?.workloads?.find((w) => w.key === workloadKey(app)) ||
|
||||||
(resourceUsage?.configured
|
(resourceUsage?.configured
|
||||||
@@ -558,22 +526,8 @@ export function ManagedServiceResourcesPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-600">GB</span>
|
<span className="text-sm text-gray-600">GB</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
|
|
||||||
disabled={
|
|
||||||
resourcesPending ||
|
|
||||||
!isDeployed ||
|
|
||||||
parseInt(dbStorageSize, 10) <= currentDbGi
|
|
||||||
}
|
|
||||||
onClick={handleExpandStorage}
|
|
||||||
>
|
|
||||||
{resizeDbMutation.isPending || calculateUpgradeCostMutation.isPending
|
|
||||||
? 'Expanding…'
|
|
||||||
: 'Expand'}
|
|
||||||
</button>
|
|
||||||
<p className="text-xs text-gray-400 w-full">
|
<p className="text-xs text-gray-400 w-full">
|
||||||
Only expansion is allowed.
|
Only expansion is allowed. Use Apply changes below.
|
||||||
{app.billingCycle
|
{app.billingCycle
|
||||||
? ' Additional storage is charged for the remaining billing period.'
|
? ' Additional storage is charged for the remaining billing period.'
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface Application {
|
|||||||
enableElasticsearch?: boolean;
|
enableElasticsearch?: boolean;
|
||||||
elasticsearchVersion?: string;
|
elasticsearchVersion?: string;
|
||||||
logPaths?: string[];
|
logPaths?: string[];
|
||||||
|
optionalServiceResources?: OptionalServiceResourcesMap;
|
||||||
// Billing & Lifecycle
|
// Billing & Lifecycle
|
||||||
planId?: string;
|
planId?: string;
|
||||||
billingCycle?: BillingCycle;
|
billingCycle?: BillingCycle;
|
||||||
|
|||||||
Reference in New Issue
Block a user