Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
#!/usr/bin/env sh
# . "$(dirname -- "$0")/_/husky.sh"

npm run lint
npm run lint:ci
npm run build:apps
npm run console-app:lint
npm run console-app:lint:ci
npm run build:console-app

# npx lint-staged
10 changes: 9 additions & 1 deletion apps/control-panel-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ PUBLIC_API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080,http://lo

# Optional: broader list for console/auth (defaults to PUBLIC_API_ALLOWED_ORIGINS)
CORS_ALLOWED_ORIGINS=https://kubeara.dev,https://www.kubeara.dev,https://app.kubeara.dev

# Grafana Cloud Loki (optional β€” logs still go to stdout when unset)
# GRAFANA_CLOUD_LOKI_URL=https://logs-prod-XXX.grafana.net/loki/api/v1/push
# GRAFANA_CLOUD_LOKI_USER=123456
# GRAFANA_CLOUD_LOKI_API_KEY=glc_...
# KUBEARA_ENV=PROD
# KUBEARA_HOST_LABEL=control-panel-local
# LOG_LEVEL=info
CONSOLE_URL=http://localhost:4000
MCP_OAUTH_ISSUER=https://9f0f-49-200-183-34.ngrok-free.app
MCP_OAUTH_RESOURCE=https://9f0f-49-200-183-34.ngrok-free.app/api/mcp
MCP_OAUTH_RESOURCE=https://9f0f-49-200-183-34.ngrok-free.app/api/mcp
8 changes: 8 additions & 0 deletions apps/control-panel-app/deploy/.env.control-panel.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=kubeara

# Grafana Cloud Loki (optional β€” see deploy/.env.monitoring.example)
# GRAFANA_CLOUD_LOKI_URL=
# GRAFANA_CLOUD_LOKI_USER=
# GRAFANA_CLOUD_LOKI_API_KEY=
# KUBEARA_ENV=PROD
# KUBEARA_HOST_LABEL=control-panel-1
# LOG_LEVEL=info
31 changes: 31 additions & 0 deletions apps/control-panel-app/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,40 @@ docker pull kubeara/agent:prod
| `VITE_API_URL` | Console API base URL incl. `/api` (e.g. `http://localhost:3000/api`; origin-only also works β€” SPA normalizes) |
| `DB_HOST` | `postgres` inside compose (do not use `127.0.0.1`) |
| `DB_*` | Postgres credentials and database name |
| `GRAFANA_CLOUD_LOKI_*` | Optional Grafana Cloud Loki push (see below) |
| `KUBEARA_ENV` | Must be `PROD` (case-insensitive) for Loki shipping; also used as a Loki label |
| `KUBEARA_HOST_LABEL` | Loki label for host identity |
| `LOG_LEVEL` | Winston log level (default `info`) |

Mounted at `/app/apps/control-panel-app/.env` inside the container (same pattern as the agent).

### Grafana Cloud logs (winston-loki)

The control panel ships NestJS logs to Grafana Cloud Loki only when `KUBEARA_ENV=PROD` and these variables are set:

| Variable | Purpose |
|----------|---------|
| `GRAFANA_CLOUD_LOKI_URL` | Push URL from Grafana Cloud (ends with `/loki/api/v1/push`) |
| `GRAFANA_CLOUD_LOKI_USER` | Numeric user / instance ID |
| `GRAFANA_CLOUD_LOKI_API_KEY` | Access policy token with `logs:write` |

Copy `deploy/.env.monitoring.example` to `.env.monitoring`, fill in credentials from your Grafana Cloud stack portal, then either merge the values into `.env.control-panel` or pass both files:

```bash
docker compose -f docker-compose.control-panel.yml \
--env-file .env.control-panel --env-file .env.monitoring up -d
```

In Grafana β†’ Explore β†’ Loki, query logs with:

```logql
{service="control-panel-app"}
```

Add `env` and `host` labels for filtering: `{service="control-panel-app", env="PROD"}`.

When `KUBEARA_ENV` is not `PROD` or Loki credentials are unset, logs continue to stdout only (Docker still captures them).

### .env.agent

| Variable | Purpose |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ services:
COOKIE_SAME_SITE: ${COOKIE_SAME_SITE:-lax}
CONTROL_PANEL_URL: ${CONTROL_PANEL_URL:-}
KUBEARA_AGENT_IMAGE: ${KUBEARA_AGENT_IMAGE:-kubeara/agent:prod}
GRAFANA_CLOUD_LOKI_URL: ${GRAFANA_CLOUD_LOKI_URL:-}
GRAFANA_CLOUD_LOKI_USER: ${GRAFANA_CLOUD_LOKI_USER:-}
GRAFANA_CLOUD_LOKI_API_KEY: ${GRAFANA_CLOUD_LOKI_API_KEY:-}
KUBEARA_ENV: ${KUBEARA_ENV:-}
KUBEARA_HOST_LABEL: ${KUBEARA_HOST_LABEL:-control-panel}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- ./.env.control-panel:/app/apps/control-panel-app/.env:ro
depends_on:
Expand Down
2 changes: 2 additions & 0 deletions apps/control-panel-app/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { McpApiKeysModule } from "./modules/mcp-api-keys/mcp-api-keys.module";
import { McpOAuthModule } from "./modules/mcp-oauth/mcp-oauth.module";
import { McpServerModule } from "./modules/mcp-server/mcp-server.module";
import { TerminalModule } from "./modules/terminal/terminal.module";
import { LokiLoggerModule } from "./modules/loki-logger";
import { AppController } from "./app.controller";
import { isProductionEnv } from "@control-panel/constants/env.constant";

Expand Down Expand Up @@ -55,6 +56,7 @@ import { isProductionEnv } from "@control-panel/constants/env.constant";
}
},
}),
LokiLoggerModule,
ServiceTemplateModule,
DeploymentsModule,
ServerConnectionsModule,
Expand Down
10 changes: 8 additions & 2 deletions apps/control-panel-app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AppModule } from "./app.module";
import { buildCorsOptions } from "./common/config/cors.util";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseInterceptor } from "./common/interceptors/response.interceptor";
import { LokiLoggerService } from "./modules/loki-logger";
import { MCP_OAUTH_GLOBAL_PREFIX_EXCLUDES } from "./modules/mcp-oauth/constants/mcp-oauth-routes.constants";

const APP_NAME = "control-panel-app";
Expand Down Expand Up @@ -145,7 +146,12 @@ function initializeEnvironment(): void {
async function bootstrap(): Promise<void> {
initializeEnvironment();

const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});

const lokiLogger = app.get(LokiLoggerService);
app.useLogger(lokiLogger);

const configService = app.get(ConfigService);

Expand Down Expand Up @@ -175,7 +181,7 @@ async function bootstrap(): Promise<void> {

await app.listen(port);

console.log(`[${APP_NAME}] Server running on port ${port}`);
lokiLogger.log(`Server running on port ${port}`, APP_NAME);
}

void bootstrap();
2 changes: 2 additions & 0 deletions apps/control-panel-app/src/modules/loki-logger/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { LokiLoggerModule } from "./loki-logger.module";
export { LokiLoggerService } from "./loki-logger.service";
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const LOKI_LOGGER_SERVICE_NAME = "control-panel-app";

/** When `KUBEARA_ENV` equals this (case-insensitive), logs ship to Grafana Loki. */
export const LOKI_SHIPPING_ENV = "PROD";

export const LOKI_ENV_KEYS = {
PUSH_URL: "GRAFANA_CLOUD_LOKI_URL",
USER: "GRAFANA_CLOUD_LOKI_USER",
API_KEY: "GRAFANA_CLOUD_LOKI_API_KEY",
ENV_LABEL: "KUBEARA_ENV",
HOST_LABEL: "KUBEARA_HOST_LABEL",
LOG_LEVEL: "LOG_LEVEL",
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Global, Module } from "@nestjs/common";

import { LokiLoggerService } from "./loki-logger.service";

@Global()
@Module({
providers: [LokiLoggerService],
exports: [LokiLoggerService],
})
export class LokiLoggerModule {}
152 changes: 152 additions & 0 deletions apps/control-panel-app/src/modules/loki-logger/loki-logger.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
Injectable,
LoggerService,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as winston from "winston";

import {
LOKI_LOGGER_SERVICE_NAME,
LOKI_SHIPPING_ENV,
} from "./loki-logger.constants";
import {
createWinstonLogger,
resolveLokiLoggerConfig,
} from "./loki-logger.util";

type WinstonLogLevel = "info" | "error" | "warn" | "debug" | "verbose";

@Injectable()
export class LokiLoggerService
implements LoggerService, OnModuleInit, OnModuleDestroy
{
private readonly winston: winston.Logger;
private readonly lokiEnabled: boolean;

constructor(private readonly configService: ConfigService) {
const config = resolveLokiLoggerConfig(configService);

try {
const result = createWinstonLogger(configService);
this.winston = result.logger;
this.lokiEnabled = result.lokiActive;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[${LOKI_LOGGER_SERVICE_NAME}] Failed to create logger, using console fallback: ${message}`,
);
this.winston = winston.createLogger({
level: config.logLevel,
transports: [
new winston.transports.Console({
level: config.logLevel,
}),
],
});
this.lokiEnabled = false;
}
}

onModuleInit(): void {
if (this.lokiEnabled) {
this.log("Grafana Cloud Loki logging enabled", LOKI_LOGGER_SERVICE_NAME);
return;
}

this.log(
`Grafana Cloud Loki logging disabled (requires KUBEARA_ENV=${LOKI_SHIPPING_ENV} and Grafana Cloud credentials)`,
LOKI_LOGGER_SERVICE_NAME,
);
}

onModuleDestroy(): void {
for (const transport of this.winston.transports) {
try {
transport.close?.();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[${LOKI_LOGGER_SERVICE_NAME}] Failed to close log transport: ${message}`,
);
}
}
}

log(message: unknown, context?: string): void {
this.write("info", message, context);
}

error(message: unknown, trace?: string, context?: string): void {
const formatted = this.formatMessage(message);
const payload = trace ? `${formatted}\n${trace}` : formatted;
this.write("error", payload, context);
}

warn(message: unknown, context?: string): void {
this.write("warn", message, context);
}

debug(message: unknown, context?: string): void {
this.write("debug", message, context);
}

verbose(message: unknown, context?: string): void {
this.write("verbose", message, context);
}

private write(
level: WinstonLogLevel,
message: unknown,
context?: string,
): void {
const formatted = this.formatMessage(message);
const meta = context ? { context } : undefined;

try {
this.winston[level](formatted, meta);
} catch (error) {
this.fallbackConsole(level, formatted, error);
}
}

private fallbackConsole(
level: WinstonLogLevel,
message: string,
error: unknown,
): void {
const errMsg = error instanceof Error ? error.message : String(error);
console.error(
`[${LOKI_LOGGER_SERVICE_NAME}] Logger write failed (${level}): ${errMsg}`,
);

switch (level) {
case "error":
console.error(message);
break;
case "warn":
console.warn(message);
break;
default:
console.log(message);
break;
}
}

private formatMessage(message: unknown): string {
if (typeof message === "string") {
return message;
}

if (message instanceof Error) {
return message.stack || message.message;
}

try {
return JSON.stringify(message);
} catch {
return String(message);
}
}
}
Loading
Loading