chore(deps): upgrade all dependencies to latest stable
Bring backend and frontend to the latest stable releases (no pre-releases), including major upgrades that required code migration. Both projects pass typecheck and production builds. Backend - NestJS 10 -> 11 (common/core/platform-express/jwt/passport/bull/cli/ schematics/testing), @nestjs/config 3->4, @nestjs/swagger 7->11, @nestjs/typeorm 10->11 - @kubernetes/client-node 0.21 -> 1.4: migrate ~200+ call sites across 6 services to the v1 single-object argument API, unwrapped responses, err.code, setHeaderOptions for patch content-type, applyToHTTPSOptions. Add regression spec k8s-client-v1-migration.spec.ts. - typeorm 0.3 -> 1.0: relations/select string arrays -> object form - uuid 9->14 (drops @types/uuid), multer 1->2, bcrypt 5->6, helmet 7->8, class-validator 0.14->0.15 - TypeScript 5->6, ESLint 8->9, @typescript-eslint 6->8, jest 29->30, @types/node 20->24; tsconfig: strictPropertyInitialization:false, ignoreDeprecations, rootDir, explicit types[] - @nestjs/config 4: jwt.strategy uses getOrThrow; @types/express kept at 4 (Nest 11 runs Express 4) Frontend - React 18->19, Next 14->16 (async params via official codemod), Tailwind 3->4 (@tailwindcss/postcss, @import + @config, inline custom @apply), framer-motion 11->12, zustand 4->5, three 0.169->0.184, @react-three/* majors - TypeScript 5->6 (tsconfig target es5->ES2017), ESLint 8->9, eslint-config-next 14->16 Infra/docs - Dockerfiles node:20-alpine -> node:24-alpine (require-esm for k8s client) - Add UPGRADE.md / UPGRADE.en.md; refresh README tech-stack versions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -142,19 +134,11 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
/** Hostnames only resolvable from inside the workload cluster (not from a laptop). */
|
||||
private isClusterInternalHost(host: string): boolean {
|
||||
const h = host.toLowerCase();
|
||||
return (
|
||||
h.includes('svc.cluster.local') ||
|
||||
h.includes('.cluster.') ||
|
||||
h === 'elasticsearch' ||
|
||||
h === 'kibana'
|
||||
);
|
||||
return h.includes('svc.cluster.local') || h.includes('.cluster.') || h === 'elasticsearch' || h === 'kibana';
|
||||
}
|
||||
|
||||
private configuredElasticsearchHost(): string {
|
||||
return (
|
||||
this.configService.get<string>('elasticsearch.host') ||
|
||||
`${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`
|
||||
);
|
||||
return this.configService.get<string>('elasticsearch.host') || `${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`;
|
||||
}
|
||||
|
||||
/** HTTP target: loopback when we tunnel; cluster DNS when the API pod runs in-cluster. */
|
||||
@@ -213,9 +197,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
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.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) => {
|
||||
@@ -280,10 +262,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> {
|
||||
const targetClusterId = clusterId || null;
|
||||
if (
|
||||
this.portForwardChild &&
|
||||
this.portForwardClusterId === targetClusterId
|
||||
) {
|
||||
if (this.portForwardChild && this.portForwardClusterId === targetClusterId) {
|
||||
return;
|
||||
}
|
||||
if (this.portForwardChild) {
|
||||
@@ -296,18 +275,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
this.portForwardKubeconfigFile = kubeconfigFile;
|
||||
this.portForwardClusterId = targetClusterId;
|
||||
|
||||
const args = [
|
||||
'--kubeconfig',
|
||||
kubeconfigFile,
|
||||
'port-forward',
|
||||
'-n',
|
||||
this.ES_NAMESPACE,
|
||||
`svc/${this.ES_NAME}`,
|
||||
`${localPort}:9200`,
|
||||
];
|
||||
this.logger.log(
|
||||
`Starting kubectl port-forward to Elasticsearch on cluster "${cluster.name}" (local log search)`,
|
||||
);
|
||||
const args = ['--kubeconfig', kubeconfigFile, 'port-forward', '-n', this.ES_NAMESPACE, `svc/${this.ES_NAME}`, `${localPort}:9200`];
|
||||
this.logger.log(`Starting kubectl port-forward to Elasticsearch on cluster "${cluster.name}" (local log search)`);
|
||||
const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
this.portForwardChild = child;
|
||||
this.portForwardStartedByUs = true;
|
||||
@@ -318,12 +287,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
this.portForwardStartedByUs = false;
|
||||
}
|
||||
if (wasOurs) {
|
||||
const reason =
|
||||
code !== 0 && code !== null
|
||||
? `exit code ${code}`
|
||||
: signal
|
||||
? `signal ${signal}`
|
||||
: 'connection closed';
|
||||
const reason = code !== 0 && code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : 'connection closed';
|
||||
this.schedulePortForwardReconnect(reason);
|
||||
}
|
||||
});
|
||||
@@ -339,10 +303,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
* 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;
|
||||
clusterId?: string;
|
||||
}): Promise<boolean> {
|
||||
private async ensureLocalElasticsearchAccess(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<boolean> {
|
||||
if (this.ensureInFlight) {
|
||||
await this.ensureInFlight;
|
||||
return this.probeElasticsearch();
|
||||
@@ -357,10 +318,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureLocalElasticsearchAccessImpl(options?: {
|
||||
waitForCluster?: boolean;
|
||||
clusterId?: string;
|
||||
}): Promise<void> {
|
||||
private async ensureLocalElasticsearchAccessImpl(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<void> {
|
||||
if (!this.shouldAutoPortForward()) {
|
||||
return;
|
||||
}
|
||||
@@ -395,9 +353,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
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.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');
|
||||
}
|
||||
}
|
||||
@@ -413,10 +369,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
if (this.isClusterInternalHost(configured)) {
|
||||
if (this.isRunningInKubernetes()) {
|
||||
return (
|
||||
'Ensure the logging stack is deployed on the same cluster as this API pod ' +
|
||||
`(Helm release ${LOGGING_HELM_RELEASE} in namespace ${LOGGING_HELM_NAMESPACE}).`
|
||||
);
|
||||
return 'Ensure the logging stack is deployed on the same cluster as this API pod ' + `(Helm release ${LOGGING_HELM_RELEASE} in namespace ${LOGGING_HELM_NAMESPACE}).`;
|
||||
}
|
||||
return (
|
||||
'Run the API inside the cluster, or restart the API locally so it can auto port-forward Elasticsearch ' +
|
||||
@@ -427,9 +380,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async getK8sClients(clusterId?: string) {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -455,18 +406,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
async getDeployState(clusterId?: string): Promise<LoggingDeployState> {
|
||||
const { cluster } = await this.getK8sClients(clusterId);
|
||||
const helmStatus = await this.helmService.status(
|
||||
LOGGING_HELM_RELEASE,
|
||||
LOGGING_HELM_NAMESPACE,
|
||||
cluster.kubeconfig,
|
||||
);
|
||||
const helmStatus = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
|
||||
|
||||
let hasEsWorkload = false;
|
||||
try {
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
|
||||
await appsApi.readNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE);
|
||||
await appsApi.readNamespacedStatefulSet({
|
||||
name: this.ES_NAME,
|
||||
namespace: this.ES_NAMESPACE,
|
||||
});
|
||||
hasEsWorkload = true;
|
||||
} catch {
|
||||
hasEsWorkload = false;
|
||||
@@ -512,26 +462,27 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(kubeconfig);
|
||||
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
|
||||
const provisioner =
|
||||
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
|
||||
const provisioner = this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
|
||||
|
||||
try {
|
||||
await storageApi.readStorageClass(storageClass);
|
||||
await storageApi.readStorageClass({ name: storageClass });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err.statusCode !== 404 && err.body?.code !== 404) {
|
||||
if (err.code !== 404 && err.body?.code !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await storageApi.createStorageClass({
|
||||
apiVersion: 'storage.k8s.io/v1',
|
||||
kind: 'StorageClass',
|
||||
metadata: { name: storageClass },
|
||||
provisioner,
|
||||
allowVolumeExpansion: true,
|
||||
reclaimPolicy: 'Delete',
|
||||
volumeBindingMode: 'WaitForFirstConsumer',
|
||||
body: {
|
||||
apiVersion: 'storage.k8s.io/v1',
|
||||
kind: 'StorageClass',
|
||||
metadata: { name: storageClass },
|
||||
provisioner,
|
||||
allowVolumeExpansion: true,
|
||||
reclaimPolicy: 'Delete',
|
||||
volumeBindingMode: 'WaitForFirstConsumer',
|
||||
},
|
||||
});
|
||||
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
|
||||
}
|
||||
@@ -578,37 +529,25 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
try {
|
||||
// Check ES pods
|
||||
const esPods = await coreApi.listNamespacedPod(
|
||||
this.ES_NAMESPACE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'app=elasticsearch',
|
||||
);
|
||||
const esPods = await coreApi.listNamespacedPod({
|
||||
namespace: this.ES_NAMESPACE,
|
||||
labelSelector: 'app=elasticsearch',
|
||||
});
|
||||
|
||||
const kibanaPods = await coreApi.listNamespacedPod(
|
||||
this.ES_NAMESPACE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'app=kibana',
|
||||
);
|
||||
const kibanaPods = await coreApi.listNamespacedPod({
|
||||
namespace: this.ES_NAMESPACE,
|
||||
labelSelector: 'app=kibana',
|
||||
});
|
||||
|
||||
if (esPods.body.items.length === 0) {
|
||||
if (esPods.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const esPod = esPods.body.items[0];
|
||||
const isEsReady = esPod.status?.conditions?.some(
|
||||
(c) => c.type === 'Ready' && c.status === 'True',
|
||||
);
|
||||
const esPod = esPods.items[0];
|
||||
const isEsReady = esPod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True');
|
||||
|
||||
const kibanaPod = kibanaPods.body.items[0];
|
||||
const isKibanaReady = kibanaPod?.status?.conditions?.some(
|
||||
(c) => c.type === 'Ready' && c.status === 'True',
|
||||
) || false;
|
||||
const kibanaPod = kibanaPods.items[0];
|
||||
const isKibanaReady = kibanaPod?.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') || false;
|
||||
|
||||
return {
|
||||
status: isEsReady ? 'green' : 'yellow',
|
||||
@@ -640,22 +579,14 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
timeout: '10m',
|
||||
});
|
||||
} catch (error: any) {
|
||||
const release = await this.helmService.status(
|
||||
LOGGING_HELM_RELEASE,
|
||||
LOGGING_HELM_NAMESPACE,
|
||||
cluster.kubeconfig,
|
||||
);
|
||||
const release = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
|
||||
if (!release) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.warn(
|
||||
`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`,
|
||||
);
|
||||
this.logger.warn(`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`,
|
||||
);
|
||||
this.logger.log(`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`);
|
||||
|
||||
const state = await this.getDeployState(clusterId);
|
||||
|
||||
@@ -688,7 +619,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Get Elasticsearch connection info for apps
|
||||
*/
|
||||
getConnectionInfo(): { host: string; port: number; username: string; password: string } {
|
||||
getConnectionInfo(): {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
return {
|
||||
host: this.effectiveElasticsearchHost(),
|
||||
port: this.configService.get<number>('elasticsearch.port') || 9200,
|
||||
@@ -744,12 +680,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
const must: any[] = [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { ownerId: userId } },
|
||||
{ term: { 'ownerId.keyword': userId } },
|
||||
{ term: { namespace } },
|
||||
{ term: { 'namespace.keyword': namespace } },
|
||||
],
|
||||
should: [{ term: { ownerId: userId } }, { term: { 'ownerId.keyword': userId } }, { term: { namespace } }, { term: { 'namespace.keyword': namespace } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
@@ -758,10 +689,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.applicationId) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { applicationId: filters.applicationId } },
|
||||
{ term: { 'applicationId.keyword': filters.applicationId } },
|
||||
],
|
||||
should: [{ term: { applicationId: filters.applicationId } }, { term: { 'applicationId.keyword': filters.applicationId } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -784,10 +712,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.workload) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { workload: filters.workload } },
|
||||
{ term: { 'workload.keyword': filters.workload } },
|
||||
],
|
||||
should: [{ term: { workload: filters.workload } }, { term: { 'workload.keyword': filters.workload } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -796,10 +721,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.level) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { level: filters.level.toLowerCase() } },
|
||||
{ term: { 'level.keyword': filters.level.toLowerCase() } },
|
||||
],
|
||||
should: [{ term: { level: filters.level.toLowerCase() } }, { term: { 'level.keyword': filters.level.toLowerCase() } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -854,9 +776,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
private async esRequest(path: string, body: unknown, clusterId?: string): Promise<any> {
|
||||
const deployed = await this.isDeployed(clusterId);
|
||||
if (!deployed) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Central logging is not configured. Ask an administrator to deploy Elasticsearch.',
|
||||
);
|
||||
throw new ServiceUnavailableException('Central logging is not configured. Ask an administrator to deploy Elasticsearch.');
|
||||
}
|
||||
|
||||
if (this.shouldAutoPortForward()) {
|
||||
@@ -896,12 +816,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private normalizeHit(hit: any): NormalizedLogEntry {
|
||||
const src = hit._source || {};
|
||||
const message =
|
||||
src.message ||
|
||||
src.log ||
|
||||
src.msg ||
|
||||
(typeof src.error === 'string' ? src.error : src.error?.message) ||
|
||||
'';
|
||||
const message = src.message || src.log || src.msg || (typeof src.error === 'string' ? src.error : src.error?.message) || '';
|
||||
|
||||
return {
|
||||
id: hit._id || '',
|
||||
@@ -942,7 +857,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async searchLogStats(
|
||||
userId: string,
|
||||
filters: { applicationId?: string; applicationName?: string; workload?: string; period?: string },
|
||||
filters: {
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
workload?: string;
|
||||
period?: string;
|
||||
},
|
||||
clusterId?: string,
|
||||
): Promise<LogStatsResult> {
|
||||
const periodMap: Record<string, string> = {
|
||||
@@ -965,8 +885,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
query: { bool: { must } },
|
||||
size: 0,
|
||||
aggs: {
|
||||
by_level: { terms: { field: 'level.keyword', size: 10, missing: 'unknown' } },
|
||||
by_workload: { terms: { field: 'workload.keyword', size: 10, missing: 'app' } },
|
||||
by_level: {
|
||||
terms: { field: 'level.keyword', size: 10, missing: 'unknown' },
|
||||
},
|
||||
by_workload: {
|
||||
terms: { field: 'workload.keyword', size: 10, missing: 'app' },
|
||||
},
|
||||
error_count: { filter: { term: { 'level.keyword': 'error' } } },
|
||||
warn_count: { filter: { term: { 'level.keyword': 'warn' } } },
|
||||
},
|
||||
@@ -994,7 +918,13 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async searchRecentErrors(
|
||||
userId: string,
|
||||
filters: { applicationId?: string; applicationName?: string; workload?: string; hours?: number; limit?: number },
|
||||
filters: {
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
workload?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
},
|
||||
clusterId?: string,
|
||||
): Promise<NormalizedLogEntry[]> {
|
||||
const hours = filters.hours || 24;
|
||||
@@ -1019,9 +949,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h));
|
||||
}
|
||||
|
||||
async getLoggingStatus(
|
||||
clusterId?: string,
|
||||
): Promise<{ available: boolean; deployed: boolean; recovering?: boolean; message?: string }> {
|
||||
async getLoggingStatus(clusterId?: string): 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);
|
||||
|
||||
Reference in New Issue
Block a user