Compare commits

...

11 Commits

Author SHA1 Message Date
David
be1de882c3 chore: sync dev with preprod
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Frontend — Lint & Type-check (push) Successful in 10m55s
Dev CI / Backend — Unit Tests (push) Successful in 10m10s
Dev CI / Frontend — Unit Tests (push) Successful in 10m30s
Dev CI / Notify Failure (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:16:16 +02:00
David
40d917e160 chore(ci): remove smoke tests from preprod and prod pipelines
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m22s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m55s
CD Preprod / Backend — Unit Tests (push) Successful in 10m10s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m34s
CD Preprod / Backend — Integration Tests (push) Successful in 9m57s
CD Preprod / Build Frontend (push) Successful in 47s
CD Preprod / Build Log Exporter (push) Successful in 33s
CD Preprod / Build Backend (push) Successful in 7m24s
CD Preprod / Deploy to Preprod (push) Successful in 24s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:13:17 +02:00
David
a5b21436c7 fix
Some checks failed
CD Preprod / Backend — Lint (push) Successful in 10m21s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m54s
CD Preprod / Backend — Unit Tests (push) Successful in 10m12s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m32s
CD Preprod / Backend — Integration Tests (push) Successful in 9m57s
CD Preprod / Build Backend (push) Successful in 50s
CD Preprod / Build Log Exporter (push) Successful in 26s
CD Preprod / Build Frontend (push) Successful in 19m3s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Success (push) Has been cancelled
CD Preprod / Smoke Tests (push) Has been cancelled
CD Preprod / Notify Failure (push) Has been cancelled
2026-04-06 15:21:01 +02:00
David
bbf059cce9 fix preprod
Some checks failed
CD Preprod / Backend — Lint (push) Successful in 10m22s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m55s
CD Preprod / Backend — Unit Tests (push) Successful in 10m12s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m33s
CD Preprod / Backend — Integration Tests (push) Successful in 9m57s
CD Preprod / Build Backend (push) Successful in 7m51s
CD Preprod / Build Log Exporter (push) Successful in 34s
CD Preprod / Build Frontend (push) Successful in 19m46s
CD Preprod / Deploy to Preprod (push) Failing after 1s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Smoke Tests (push) Has been skipped
CD Preprod / Notify Success (push) Has been skipped
2026-04-06 14:21:32 +02:00
David
850c23c164 fix
Some checks failed
CD Preprod / Backend — Lint (push) Successful in 10m27s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m59s
CD Preprod / Backend — Unit Tests (push) Successful in 10m8s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m32s
CD Preprod / Backend — Integration Tests (push) Successful in 9m57s
CD Preprod / Build Frontend (push) Successful in 33s
CD Preprod / Build Backend (push) Successful in 53s
CD Preprod / Build Log Exporter (push) Successful in 1m16s
CD Preprod / Deploy to Preprod (push) Failing after 1s
CD Preprod / Smoke Tests (push) Has been skipped
CD Preprod / Notify Success (push) Has been skipped
CD Preprod / Notify Failure (push) Has been skipped
2026-04-06 13:09:03 +02:00
David
72141c5f68 fix preprod
Some checks failed
CD Preprod / Backend — Lint (push) Successful in 10m27s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m55s
CD Preprod / Backend — Unit Tests (push) Successful in 10m10s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m33s
CD Preprod / Backend — Integration Tests (push) Successful in 10m6s
CD Preprod / Build Backend (push) Successful in 16m5s
CD Preprod / Build Frontend (push) Successful in 35m0s
CD Preprod / Deploy to Preprod (push) Failing after 1s
CD Preprod / Smoke Tests (push) Has been skipped
CD Preprod / Notify Success (push) Has been skipped
CD Preprod / Notify Failure (push) Has been skipped
2026-04-04 17:58:36 +02:00
David
fe7cd1f792 Merge branch 'dev' into preprod
Some checks failed
CD Preprod / Backend — Lint (push) Successful in 10m24s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m0s
CD Preprod / Backend — Unit Tests (push) Failing after 5m33s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m33s
CD Preprod / Backend — Integration Tests (push) Has been skipped
CD Preprod / Build Backend (push) Has been skipped
CD Preprod / Build Frontend (push) Has been skipped
CD Preprod / Deploy to Preprod (push) Has been skipped
CD Preprod / Smoke Tests (push) Has been skipped
CD Preprod / Notify Success (push) Has been skipped
CD Preprod / Notify Failure (push) Has been skipped
2026-04-04 17:02:39 +02:00
David
eb285033c0 fix(cicd): sync corrected pipelines from cicd branch
Some checks failed
CD Preprod / Frontend — Lint & Type-check (push) Failing after 6m9s
CD Preprod / Frontend — Unit Tests (push) Has been skipped
CD Preprod / Backend — Lint (push) Successful in 10m22s
CD Preprod / Backend — Unit Tests (push) Failing after 5m29s
CD Preprod / Backend — Integration Tests (push) Has been skipped
CD Preprod / Build Backend (push) Has been skipped
CD Preprod / Build Frontend (push) Has been skipped
CD Preprod / Deploy to Preprod (push) Has been skipped
CD Preprod / Smoke Tests (push) Has been skipped
CD Preprod / Notify Success (push) Has been skipped
CD Preprod / Notify Failure (push) Has been skipped
2026-04-04 13:16:47 +02:00
David
e5f03e22f2 chore: sync root-level docs with main and dev
Some checks failed
CD Preprod / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
CD Preprod / Integration Tests (push) Blocked by required conditions
CD Preprod / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
CD Preprod / Build & Push Backend (push) Blocked by required conditions
CD Preprod / Build & Push Frontend (push) Blocked by required conditions
CD Preprod / Deploy to Preprod (push) Blocked by required conditions
CD Preprod / Smoke Tests (push) Blocked by required conditions
CD Preprod / Deployment Summary (push) Blocked by required conditions
CD Preprod / Notify Success (push) Blocked by required conditions
CD Preprod / Notify Failure (push) Blocked by required conditions
CD Preprod / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
CD Preprod / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
2026-04-04 13:03:34 +02:00
David
21e9584907 chore: sync full codebase from cicd branch
Some checks failed
CD Preprod / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
CD Preprod / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
CD Preprod / Integration Tests (push) Blocked by required conditions
CD Preprod / Build & Push Backend (push) Blocked by required conditions
CD Preprod / Build & Push Frontend (push) Blocked by required conditions
CD Preprod / Deploy to Preprod (push) Blocked by required conditions
CD Preprod / Smoke Tests (push) Blocked by required conditions
CD Preprod / Deployment Summary (push) Blocked by required conditions
CD Preprod / Notify Success (push) Blocked by required conditions
CD Preprod / Notify Failure (push) Blocked by required conditions
CD Preprod / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
CD Preprod / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Aligns preprod with the complete application codebase (cicd branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:56:28 +02:00
David
ab0ed187ed feat(cicd): sync CI/CD pipeline from cicd branch
Some checks failed
CD Preprod / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
CD Preprod / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
CD Preprod / Integration Tests (push) Blocked by required conditions
CD Preprod / Build & Push Backend (push) Blocked by required conditions
CD Preprod / Build & Push Frontend (push) Blocked by required conditions
CD Preprod / Deploy to Preprod (push) Blocked by required conditions
CD Preprod / Smoke Tests (push) Blocked by required conditions
CD Preprod / Deployment Summary (push) Blocked by required conditions
CD Preprod / Notify Success (push) Blocked by required conditions
CD Preprod / Notify Failure (push) Blocked by required conditions
CD Preprod / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
CD Preprod / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:52:46 +02:00
19 changed files with 732 additions and 757 deletions

View File

@ -10,7 +10,7 @@ name: CD Production
# If someone merges to main without going through preprod, # If someone merges to main without going through preprod,
# this step fails and the deployment is blocked. # this step fails and the deployment is blocked.
# #
# Flow: quality-gate → verify-image → promote → deploy → smoke-tests → notify # Flow: quality-gate → verify-image → promote → deploy → notify
# #
# Secrets required: # Secrets required:
# REGISTRY_TOKEN — Scaleway registry (read/write) # REGISTRY_TOKEN — Scaleway registry (read/write)
@ -231,47 +231,11 @@ jobs:
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
echo "Rollback complete. Previous version is live." echo "Rollback complete. Previous version is live."
# ── 5. Smoke Tests ───────────────────────────────────────────────────
# kubectl rollout status already verified pod readiness.
# These smoke tests validate the full network path:
# Cloudflare → Hetzner LB → Traefik → pod.
smoke-tests:
name: Smoke Tests
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Wait for LB propagation
run: sleep 30
- name: Health — Backend
run: |
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"${{ secrets.PROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi
sleep 15
done
echo "CRITICAL: Backend unreachable after 12 attempts."
exit 1
- name: Health — Frontend
run: |
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"${{ secrets.PROD_FRONTEND_URL }}" 2>/dev/null || echo "000")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi
sleep 15
done
echo "CRITICAL: Frontend unreachable after 12 attempts."
exit 1
# ── Notifications ──────────────────────────────────────────────────── # ── Notifications ────────────────────────────────────────────────────
notify-success: notify-success:
name: Notify Success name: Notify Success
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [verify-image, smoke-tests] needs: [verify-image, deploy]
if: success() if: success()
steps: steps:
- run: | - run: |
@ -292,7 +256,7 @@ jobs:
notify-failure: notify-failure:
name: Notify Failure name: Notify Failure
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy, smoke-tests] needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy]
if: failure() if: failure()
steps: steps:
- run: | - run: |

View File

@ -1,7 +1,7 @@
name: CD Preprod name: CD Preprod
# Full pipeline triggered on every push to preprod. # Full pipeline triggered on every push to preprod.
# Flow: lint → unit tests → integration tests → docker build → deploy → smoke tests → notify # Flow: lint → unit tests → integration tests → docker build → deploy → notify
# #
# Secrets required: # Secrets required:
# REGISTRY_TOKEN — Scaleway registry (read/write) # REGISTRY_TOKEN — Scaleway registry (read/write)
@ -217,60 +217,68 @@ jobs:
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }} NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}
build-log-exporter:
name: Build Log Exporter
runs-on: ubuntu-latest
needs: integration-tests
outputs:
sha: ${{ steps.sha.outputs.short }}
steps:
- uses: actions/checkout@v4
- name: Short SHA
id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./apps/log-exporter
file: ./apps/log-exporter/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/xpeditis-log-exporter:preprod
${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache,mode=max
platforms: linux/amd64,linux/arm64
# ── 5. Deploy via Portainer ────────────────────────────────────────── # ── 5. Deploy via Portainer ──────────────────────────────────────────
deploy: deploy:
name: Deploy to Preprod name: Deploy to Preprod
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-backend, build-frontend] needs: [build-backend, build-frontend, build-log-exporter]
environment: preprod environment: preprod
steps: steps:
- name: Deploy backend - name: Deploy backend
run: | run: |
curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}")
echo "Portainer response: HTTP $HTTP_CODE"
if [[ "$HTTP_CODE" != "2"* ]]; then
echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Backend webhook triggered." echo "Backend webhook triggered."
- name: Wait for backend startup - name: Wait for backend startup
run: sleep 20 run: sleep 20
- name: Deploy frontend - name: Deploy frontend
run: | run: |
curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}")
echo "Portainer response: HTTP $HTTP_CODE"
if [[ "$HTTP_CODE" != "2"* ]]; then
echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Frontend webhook triggered." echo "Frontend webhook triggered."
# ── 6. Smoke Tests ───────────────────────────────────────────────────
smoke-tests:
name: Smoke Tests
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Wait for services
run: sleep 40
- name: Health — Backend
run: |
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"${{ secrets.PREPROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi
sleep 15
done
echo "Backend unreachable after 12 attempts."
exit 1
- name: Health — Frontend
run: |
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"${{ secrets.PREPROD_FRONTEND_URL }}" 2>/dev/null || echo "000")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi
sleep 15
done
echo "Frontend unreachable after 12 attempts."
exit 1
# ── Notifications ──────────────────────────────────────────────────── # ── Notifications ────────────────────────────────────────────────────
notify-success: notify-success:
name: Notify Success name: Notify Success
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-backend, build-frontend, smoke-tests] needs: [build-backend, build-frontend, deploy]
if: success() if: success()
steps: steps:
- run: | - run: |
@ -290,7 +298,7 @@ jobs:
notify-failure: notify-failure:
name: Notify Failure name: Notify Failure
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy, smoke-tests] needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy]
if: failure() if: failure()
steps: steps:
- run: | - run: |

2
.gitignore vendored
View File

@ -44,6 +44,8 @@ lerna-debug.log*
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
stack-portainer.yaml
tmp.stack-portainer.yaml
# Uploads # Uploads
uploads/ uploads/

View File

@ -19,6 +19,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module'; import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module'; import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module'; import { AdminModule } from './application/admin/admin.module';
import { LogsModule } from './application/logs/logs.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module'; import { ApiKeysModule } from './application/api-keys/api-keys.module';
import { CacheModule } from './infrastructure/cache/cache.module'; import { CacheModule } from './infrastructure/cache/cache.module';
@ -67,6 +68,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'),
}), }),
}), }),
@ -147,6 +149,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
AdminModule, AdminModule,
SubscriptionsModule, SubscriptionsModule,
ApiKeysModule, ApiKeysModule,
LogsModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

View File

@ -311,12 +311,12 @@ export class AuthService {
* Generate access and refresh tokens * Generate access and refresh tokens
*/ */
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get ENTERPRISE plan with no expiration // ADMIN users always get PLATINIUM plan with no expiration
let plan = 'FREE'; let plan = 'BRONZE';
let planFeatures: string[] = []; let planFeatures: string[] = [];
if (user.role === UserRole.ADMIN) { if (user.role === UserRole.ADMIN) {
plan = 'ENTERPRISE'; plan = 'PLATINIUM';
planFeatures = [ planFeatures = [
'dashboard', 'dashboard',
'wiki', 'wiki',

View File

@ -1,6 +1,8 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
@Public()
@ApiTags('health') @ApiTags('health')
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {

View File

@ -11,10 +11,10 @@ import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator';
* Subscription plan types * Subscription plan types
*/ */
export enum SubscriptionPlanDto { export enum SubscriptionPlanDto {
FREE = 'FREE', BRONZE = 'BRONZE',
STARTER = 'STARTER', SILVER = 'SILVER',
PRO = 'PRO', GOLD = 'GOLD',
ENTERPRISE = 'ENTERPRISE', PLATINIUM = 'PLATINIUM',
} }
/** /**
@ -44,7 +44,7 @@ export enum BillingIntervalDto {
*/ */
export class CreateCheckoutSessionDto { export class CreateCheckoutSessionDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
description: 'The subscription plan to purchase', description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
@ -188,7 +188,7 @@ export class LicenseResponseDto {
*/ */
export class PlanDetailsDto { export class PlanDetailsDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
description: 'Plan identifier', description: 'Plan identifier',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
@ -274,7 +274,7 @@ export class SubscriptionResponseDto {
organizationId: string; organizationId: string;
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
description: 'Current subscription plan', description: 'Current subscription plan',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })

View File

@ -0,0 +1,98 @@
import {
Controller,
Get,
Query,
Res,
UseGuards,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
@Controller('logs')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
export class LogsController {
private readonly logExporterUrl: string;
constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>(
'LOG_EXPORTER_URL',
'http://xpeditis-log-exporter:3200',
);
}
/**
* GET /api/v1/logs/services
* Proxy log-exporter /api/logs/services
*/
@Get('services')
async getServices() {
try {
const res = await fetch(`${this.logExporterUrl}/api/logs/services`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
return res.json();
} catch (err: any) {
throw new HttpException(
{ error: err.message },
HttpStatus.BAD_GATEWAY,
);
}
}
/**
* GET /api/v1/logs/export
* Proxy log-exporter /api/logs/export (JSON or CSV)
*/
@Get('export')
async exportLogs(
@Query('service') service: string,
@Query('level') level: string,
@Query('search') search: string,
@Query('start') start: string,
@Query('end') end: string,
@Query('limit') limit: string,
@Query('format') format: string = 'json',
@Res() res: Response,
) {
try {
const params = new URLSearchParams();
if (service) params.set('service', service);
if (level) params.set('level', level);
if (search) params.set('search', search);
if (start) params.set('start', start);
if (end) params.set('end', end);
if (limit) params.set('limit', limit);
params.set('format', format);
const upstream = await fetch(
`${this.logExporterUrl}/api/logs/export?${params}`,
{ signal: AbortSignal.timeout(30000) },
);
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({}));
throw new HttpException(body, upstream.status);
}
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
if (['content-type', 'content-disposition'].includes(key.toLowerCase())) {
res.setHeader(key, value);
}
});
const buffer = await upstream.arrayBuffer();
res.send(Buffer.from(buffer));
} catch (err: any) {
if (err instanceof HttpException) throw err;
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
}
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LogsController } from './logs.controller';
@Module({
imports: [ConfigModule],
controllers: [LogsController],
})
export class LogsModule {}

View File

@ -182,8 +182,8 @@ export class SubscriptionService {
} }
// Cannot checkout for FREE plan // Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.FREE) { if (dto.plan === SubscriptionPlanDto.BRONZE) {
throw new BadRequestException('Cannot create checkout session for Free plan'); throw new BadRequestException('Cannot create checkout session for Bronze plan');
} }
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);

View File

@ -21,12 +21,12 @@ describe('Subscription Entity', () => {
}; };
describe('create', () => { describe('create', () => {
it('should create a subscription with default FREE plan', () => { it('should create a subscription with default BRONZE plan', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.id).toBe('sub-123'); expect(subscription.id).toBe('sub-123');
expect(subscription.organizationId).toBe('org-123'); expect(subscription.organizationId).toBe('org-123');
expect(subscription.plan.value).toBe('FREE'); expect(subscription.plan.value).toBe('BRONZE');
expect(subscription.status.value).toBe('ACTIVE'); expect(subscription.status.value).toBe('ACTIVE');
expect(subscription.cancelAtPeriodEnd).toBe(false); expect(subscription.cancelAtPeriodEnd).toBe(false);
}); });
@ -35,10 +35,10 @@ describe('Subscription Entity', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.starter(), plan: SubscriptionPlan.silver(),
}); });
expect(subscription.plan.value).toBe('STARTER'); expect(subscription.plan.value).toBe('SILVER');
}); });
it('should create a subscription with Stripe IDs', () => { it('should create a subscription with Stripe IDs', () => {
@ -59,7 +59,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({ const subscription = Subscription.fromPersistence({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: 'PRO', plan: 'GOLD',
status: 'ACTIVE', status: 'ACTIVE',
stripeCustomerId: 'cus_123', stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_stripe_123', stripeSubscriptionId: 'sub_stripe_123',
@ -71,57 +71,57 @@ describe('Subscription Entity', () => {
}); });
expect(subscription.id).toBe('sub-123'); expect(subscription.id).toBe('sub-123');
expect(subscription.plan.value).toBe('PRO'); expect(subscription.plan.value).toBe('GOLD');
expect(subscription.status.value).toBe('ACTIVE'); expect(subscription.status.value).toBe('ACTIVE');
expect(subscription.cancelAtPeriodEnd).toBe(true); expect(subscription.cancelAtPeriodEnd).toBe(true);
}); });
}); });
describe('maxLicenses', () => { describe('maxLicenses', () => {
it('should return correct limits for FREE plan', () => { it('should return correct limits for BRONZE plan', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.maxLicenses).toBe(2); expect(subscription.maxLicenses).toBe(1);
}); });
it('should return correct limits for STARTER plan', () => { it('should return correct limits for SILVER plan', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.starter(), plan: SubscriptionPlan.silver(),
}); });
expect(subscription.maxLicenses).toBe(5); expect(subscription.maxLicenses).toBe(5);
}); });
it('should return correct limits for PRO plan', () => { it('should return correct limits for GOLD plan', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.pro(), plan: SubscriptionPlan.gold(),
}); });
expect(subscription.maxLicenses).toBe(20); expect(subscription.maxLicenses).toBe(20);
}); });
it('should return -1 for ENTERPRISE plan (unlimited)', () => { it('should return -1 for PLATINIUM plan (unlimited)', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.enterprise(), plan: SubscriptionPlan.platinium(),
}); });
expect(subscription.maxLicenses).toBe(-1); expect(subscription.maxLicenses).toBe(-1);
}); });
}); });
describe('isUnlimited', () => { describe('isUnlimited', () => {
it('should return false for FREE plan', () => { it('should return false for BRONZE plan', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.isUnlimited()).toBe(false); expect(subscription.isUnlimited()).toBe(false);
}); });
it('should return true for ENTERPRISE plan', () => { it('should return true for PLATINIUM plan', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.enterprise(), plan: SubscriptionPlan.platinium(),
}); });
expect(subscription.isUnlimited()).toBe(true); expect(subscription.isUnlimited()).toBe(true);
}); });
@ -137,7 +137,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({ const subscription = Subscription.fromPersistence({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: 'FREE', plan: 'BRONZE',
status: 'TRIALING', status: 'TRIALING',
stripeCustomerId: null, stripeCustomerId: null,
stripeSubscriptionId: null, stripeSubscriptionId: null,
@ -154,7 +154,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({ const subscription = Subscription.fromPersistence({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: 'FREE', plan: 'BRONZE',
status: 'CANCELED', status: 'CANCELED',
stripeCustomerId: null, stripeCustomerId: null,
stripeSubscriptionId: null, stripeSubscriptionId: null,
@ -170,21 +170,20 @@ describe('Subscription Entity', () => {
describe('canAllocateLicenses', () => { describe('canAllocateLicenses', () => {
it('should return true when licenses are available', () => { it('should return true when licenses are available', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription(); // BRONZE = 1 license
expect(subscription.canAllocateLicenses(0, 1)).toBe(true); expect(subscription.canAllocateLicenses(0, 1)).toBe(true);
expect(subscription.canAllocateLicenses(1, 1)).toBe(true);
}); });
it('should return false when no licenses available', () => { it('should return false when no licenses available', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses expect(subscription.canAllocateLicenses(1, 1)).toBe(false); // BRONZE has 1 license max
}); });
it('should always return true for ENTERPRISE plan', () => { it('should always return true for PLATINIUM plan', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.enterprise(), plan: SubscriptionPlan.platinium(),
}); });
expect(subscription.canAllocateLicenses(1000, 100)).toBe(true); expect(subscription.canAllocateLicenses(1000, 100)).toBe(true);
}); });
@ -193,7 +192,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({ const subscription = Subscription.fromPersistence({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: 'FREE', plan: 'BRONZE',
status: 'CANCELED', status: 'CANCELED',
stripeCustomerId: null, stripeCustomerId: null,
stripeSubscriptionId: null, stripeSubscriptionId: null,
@ -208,23 +207,23 @@ describe('Subscription Entity', () => {
}); });
describe('canUpgradeTo', () => { describe('canUpgradeTo', () => {
it('should allow upgrade from FREE to STARTER', () => { it('should allow upgrade from BRONZE to SILVER', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true); expect(subscription.canUpgradeTo(SubscriptionPlan.silver())).toBe(true);
}); });
it('should allow upgrade from FREE to PRO', () => { it('should allow upgrade from BRONZE to GOLD', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true); expect(subscription.canUpgradeTo(SubscriptionPlan.gold())).toBe(true);
}); });
it('should not allow downgrade via canUpgradeTo', () => { it('should not allow downgrade via canUpgradeTo', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.starter(), plan: SubscriptionPlan.silver(),
}); });
expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false); expect(subscription.canUpgradeTo(SubscriptionPlan.bronze())).toBe(false);
}); });
}); });
@ -233,34 +232,34 @@ describe('Subscription Entity', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.starter(), plan: SubscriptionPlan.silver(),
}); });
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true);
}); });
it('should prevent downgrade when user count exceeds new plan', () => { it('should prevent downgrade when user count exceeds new plan', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.starter(), plan: SubscriptionPlan.silver(),
}); });
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false);
}); });
}); });
describe('updatePlan', () => { describe('updatePlan', () => {
it('should update to new plan when valid', () => { it('should update to new plan when valid', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1); const updated = subscription.updatePlan(SubscriptionPlan.silver(), 1);
expect(updated.plan.value).toBe('STARTER'); expect(updated.plan.value).toBe('SILVER');
}); });
it('should throw when subscription is not active', () => { it('should throw when subscription is not active', () => {
const subscription = Subscription.fromPersistence({ const subscription = Subscription.fromPersistence({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: 'FREE', plan: 'BRONZE',
status: 'CANCELED', status: 'CANCELED',
stripeCustomerId: null, stripeCustomerId: null,
stripeSubscriptionId: null, stripeSubscriptionId: null,
@ -271,7 +270,7 @@ describe('Subscription Entity', () => {
updatedAt: new Date(), updatedAt: new Date(),
}); });
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 0)).toThrow(
SubscriptionNotActiveException SubscriptionNotActiveException
); );
}); });
@ -280,10 +279,10 @@ describe('Subscription Entity', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.pro(), plan: SubscriptionPlan.gold(),
}); });
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 10)).toThrow(
InvalidSubscriptionDowngradeException InvalidSubscriptionDowngradeException
); );
}); });
@ -341,7 +340,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({ const subscription = Subscription.fromPersistence({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: 'STARTER', plan: 'SILVER',
status: 'ACTIVE', status: 'ACTIVE',
stripeCustomerId: 'cus_123', stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_123', stripeSubscriptionId: 'sub_123',
@ -368,17 +367,17 @@ describe('Subscription Entity', () => {
}); });
describe('isFree and isPaid', () => { describe('isFree and isPaid', () => {
it('should return true for isFree when FREE plan', () => { it('should return true for isFree when BRONZE plan', () => {
const subscription = createValidSubscription(); const subscription = createValidSubscription();
expect(subscription.isFree()).toBe(true); expect(subscription.isFree()).toBe(true);
expect(subscription.isPaid()).toBe(false); expect(subscription.isPaid()).toBe(false);
}); });
it('should return true for isPaid when STARTER plan', () => { it('should return true for isPaid when SILVER plan', () => {
const subscription = Subscription.create({ const subscription = Subscription.create({
id: 'sub-123', id: 'sub-123',
organizationId: 'org-123', organizationId: 'org-123',
plan: SubscriptionPlan.starter(), plan: SubscriptionPlan.silver(),
}); });
expect(subscription.isFree()).toBe(false); expect(subscription.isFree()).toBe(false);
expect(subscription.isPaid()).toBe(true); expect(subscription.isPaid()).toBe(true);
@ -397,7 +396,7 @@ describe('Subscription Entity', () => {
expect(obj.id).toBe('sub-123'); expect(obj.id).toBe('sub-123');
expect(obj.organizationId).toBe('org-123'); expect(obj.organizationId).toBe('org-123');
expect(obj.plan).toBe('FREE'); expect(obj.plan).toBe('BRONZE');
expect(obj.status).toBe('ACTIVE'); expect(obj.status).toBe('ACTIVE');
expect(obj.stripeCustomerId).toBe('cus_123'); expect(obj.stripeCustomerId).toBe('cus_123');
}); });

View File

@ -24,13 +24,13 @@ export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [
'dedicated_kam', 'dedicated_kam',
]; ];
export type SubscriptionPlanTypeForFeatures = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = { export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
FREE: [], BRONZE: [],
STARTER: ['dashboard', 'wiki', 'user_management', 'csv_export'], SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'],
PRO: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'],
ENTERPRISE: [ PLATINIUM: [
'dashboard', 'dashboard',
'wiki', 'wiki',
'user_management', 'user_management',

View File

@ -8,31 +8,56 @@ import { SubscriptionPlan } from './subscription-plan.vo';
describe('SubscriptionPlan Value Object', () => { describe('SubscriptionPlan Value Object', () => {
describe('static factory methods', () => { describe('static factory methods', () => {
it('should create FREE plan', () => { it('should create BRONZE plan via bronze()', () => {
const plan = SubscriptionPlan.bronze();
expect(plan.value).toBe('BRONZE');
});
it('should create SILVER plan via silver()', () => {
const plan = SubscriptionPlan.silver();
expect(plan.value).toBe('SILVER');
});
it('should create GOLD plan via gold()', () => {
const plan = SubscriptionPlan.gold();
expect(plan.value).toBe('GOLD');
});
it('should create PLATINIUM plan via platinium()', () => {
const plan = SubscriptionPlan.platinium();
expect(plan.value).toBe('PLATINIUM');
});
it('should create BRONZE plan via free() alias', () => {
const plan = SubscriptionPlan.free(); const plan = SubscriptionPlan.free();
expect(plan.value).toBe('FREE'); expect(plan.value).toBe('BRONZE');
}); });
it('should create STARTER plan', () => { it('should create SILVER plan via starter() alias', () => {
const plan = SubscriptionPlan.starter(); const plan = SubscriptionPlan.starter();
expect(plan.value).toBe('STARTER'); expect(plan.value).toBe('SILVER');
}); });
it('should create PRO plan', () => { it('should create GOLD plan via pro() alias', () => {
const plan = SubscriptionPlan.pro(); const plan = SubscriptionPlan.pro();
expect(plan.value).toBe('PRO'); expect(plan.value).toBe('GOLD');
}); });
it('should create ENTERPRISE plan', () => { it('should create PLATINIUM plan via enterprise() alias', () => {
const plan = SubscriptionPlan.enterprise(); const plan = SubscriptionPlan.enterprise();
expect(plan.value).toBe('ENTERPRISE'); expect(plan.value).toBe('PLATINIUM');
}); });
}); });
describe('create', () => { describe('create', () => {
it('should create plan from valid type', () => { it('should create plan from valid type SILVER', () => {
const plan = SubscriptionPlan.create('STARTER'); const plan = SubscriptionPlan.create('SILVER');
expect(plan.value).toBe('STARTER'); expect(plan.value).toBe('SILVER');
});
it('should create plan from valid type BRONZE', () => {
const plan = SubscriptionPlan.create('BRONZE');
expect(plan.value).toBe('BRONZE');
}); });
it('should throw for invalid plan type', () => { it('should throw for invalid plan type', () => {
@ -41,9 +66,29 @@ describe('SubscriptionPlan Value Object', () => {
}); });
describe('fromString', () => { describe('fromString', () => {
it('should create plan from lowercase string', () => { it('should create SILVER from lowercase "silver"', () => {
const plan = SubscriptionPlan.fromString('silver');
expect(plan.value).toBe('SILVER');
});
it('should map legacy "starter" to SILVER', () => {
const plan = SubscriptionPlan.fromString('starter'); const plan = SubscriptionPlan.fromString('starter');
expect(plan.value).toBe('STARTER'); expect(plan.value).toBe('SILVER');
});
it('should map legacy "free" to BRONZE', () => {
const plan = SubscriptionPlan.fromString('free');
expect(plan.value).toBe('BRONZE');
});
it('should map legacy "pro" to GOLD', () => {
const plan = SubscriptionPlan.fromString('pro');
expect(plan.value).toBe('GOLD');
});
it('should map legacy "enterprise" to PLATINIUM', () => {
const plan = SubscriptionPlan.fromString('enterprise');
expect(plan.value).toBe('PLATINIUM');
}); });
it('should throw for invalid string', () => { it('should throw for invalid string', () => {
@ -52,146 +97,150 @@ describe('SubscriptionPlan Value Object', () => {
}); });
describe('maxLicenses', () => { describe('maxLicenses', () => {
it('should return 2 for FREE plan', () => { it('should return 1 for BRONZE plan', () => {
const plan = SubscriptionPlan.free(); const plan = SubscriptionPlan.bronze();
expect(plan.maxLicenses).toBe(2); expect(plan.maxLicenses).toBe(1);
}); });
it('should return 5 for STARTER plan', () => { it('should return 5 for SILVER plan', () => {
const plan = SubscriptionPlan.starter(); const plan = SubscriptionPlan.silver();
expect(plan.maxLicenses).toBe(5); expect(plan.maxLicenses).toBe(5);
}); });
it('should return 20 for PRO plan', () => { it('should return 20 for GOLD plan', () => {
const plan = SubscriptionPlan.pro(); const plan = SubscriptionPlan.gold();
expect(plan.maxLicenses).toBe(20); expect(plan.maxLicenses).toBe(20);
}); });
it('should return -1 (unlimited) for ENTERPRISE plan', () => { it('should return -1 (unlimited) for PLATINIUM plan', () => {
const plan = SubscriptionPlan.enterprise(); const plan = SubscriptionPlan.platinium();
expect(plan.maxLicenses).toBe(-1); expect(plan.maxLicenses).toBe(-1);
}); });
}); });
describe('isUnlimited', () => { describe('isUnlimited', () => {
it('should return false for FREE plan', () => { it('should return false for BRONZE plan', () => {
expect(SubscriptionPlan.free().isUnlimited()).toBe(false); expect(SubscriptionPlan.bronze().isUnlimited()).toBe(false);
}); });
it('should return false for STARTER plan', () => { it('should return false for SILVER plan', () => {
expect(SubscriptionPlan.starter().isUnlimited()).toBe(false); expect(SubscriptionPlan.silver().isUnlimited()).toBe(false);
}); });
it('should return false for PRO plan', () => { it('should return false for GOLD plan', () => {
expect(SubscriptionPlan.pro().isUnlimited()).toBe(false); expect(SubscriptionPlan.gold().isUnlimited()).toBe(false);
}); });
it('should return true for ENTERPRISE plan', () => { it('should return true for PLATINIUM plan', () => {
expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true); expect(SubscriptionPlan.platinium().isUnlimited()).toBe(true);
}); });
}); });
describe('isPaid', () => { describe('isPaid', () => {
it('should return false for FREE plan', () => { it('should return false for BRONZE plan', () => {
expect(SubscriptionPlan.free().isPaid()).toBe(false); expect(SubscriptionPlan.bronze().isPaid()).toBe(false);
}); });
it('should return true for STARTER plan', () => { it('should return true for SILVER plan', () => {
expect(SubscriptionPlan.starter().isPaid()).toBe(true); expect(SubscriptionPlan.silver().isPaid()).toBe(true);
}); });
it('should return true for PRO plan', () => { it('should return true for GOLD plan', () => {
expect(SubscriptionPlan.pro().isPaid()).toBe(true); expect(SubscriptionPlan.gold().isPaid()).toBe(true);
}); });
it('should return true for ENTERPRISE plan', () => { it('should return true for PLATINIUM plan', () => {
expect(SubscriptionPlan.enterprise().isPaid()).toBe(true); expect(SubscriptionPlan.platinium().isPaid()).toBe(true);
}); });
}); });
describe('isFree', () => { describe('isFree', () => {
it('should return true for FREE plan', () => { it('should return true for BRONZE plan', () => {
expect(SubscriptionPlan.free().isFree()).toBe(true); expect(SubscriptionPlan.bronze().isFree()).toBe(true);
}); });
it('should return false for STARTER plan', () => { it('should return false for SILVER plan', () => {
expect(SubscriptionPlan.starter().isFree()).toBe(false); expect(SubscriptionPlan.silver().isFree()).toBe(false);
}); });
}); });
describe('canAccommodateUsers', () => { describe('canAccommodateUsers', () => {
it('should return true for FREE plan with 2 users', () => { it('should return true for BRONZE plan with 1 user', () => {
expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true); expect(SubscriptionPlan.bronze().canAccommodateUsers(1)).toBe(true);
}); });
it('should return false for FREE plan with 3 users', () => { it('should return false for BRONZE plan with 2 users', () => {
expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false); expect(SubscriptionPlan.bronze().canAccommodateUsers(2)).toBe(false);
}); });
it('should return true for STARTER plan with 5 users', () => { it('should return true for SILVER plan with 5 users', () => {
expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true); expect(SubscriptionPlan.silver().canAccommodateUsers(5)).toBe(true);
}); });
it('should always return true for ENTERPRISE plan', () => { it('should always return true for PLATINIUM plan', () => {
expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true); expect(SubscriptionPlan.platinium().canAccommodateUsers(1000)).toBe(true);
}); });
}); });
describe('canUpgradeTo', () => { describe('canUpgradeTo', () => {
it('should allow upgrade from FREE to STARTER', () => { it('should allow upgrade from BRONZE to SILVER', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true); expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.silver())).toBe(true);
}); });
it('should allow upgrade from FREE to PRO', () => { it('should allow upgrade from BRONZE to GOLD', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.gold())).toBe(true);
}); });
it('should allow upgrade from FREE to ENTERPRISE', () => { it('should allow upgrade from BRONZE to PLATINIUM', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true); expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.platinium())).toBe(true);
}); });
it('should allow upgrade from STARTER to PRO', () => { it('should allow upgrade from SILVER to GOLD', () => {
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.gold())).toBe(true);
}); });
it('should not allow downgrade from STARTER to FREE', () => { it('should not allow downgrade from SILVER to BRONZE', () => {
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false); expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.bronze())).toBe(false);
}); });
it('should not allow same plan upgrade', () => { it('should not allow same plan upgrade', () => {
expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false); expect(SubscriptionPlan.gold().canUpgradeTo(SubscriptionPlan.gold())).toBe(false);
}); });
}); });
describe('canDowngradeTo', () => { describe('canDowngradeTo', () => {
it('should allow downgrade from STARTER to FREE when users fit', () => { it('should allow downgrade from SILVER to BRONZE when users fit', () => {
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true);
}); });
it('should not allow downgrade from STARTER to FREE when users exceed', () => { it('should not allow downgrade from SILVER to BRONZE when users exceed', () => {
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false);
}); });
it('should not allow upgrade via canDowngradeTo', () => { it('should not allow upgrade via canDowngradeTo', () => {
expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false); expect(SubscriptionPlan.bronze().canDowngradeTo(SubscriptionPlan.silver(), 1)).toBe(false);
}); });
}); });
describe('plan details', () => { describe('plan details', () => {
it('should return correct name for FREE plan', () => { it('should return correct name for BRONZE plan', () => {
expect(SubscriptionPlan.free().name).toBe('Free'); expect(SubscriptionPlan.bronze().name).toBe('Bronze');
}); });
it('should return correct prices for STARTER plan', () => { it('should return correct name for SILVER plan', () => {
const plan = SubscriptionPlan.starter(); expect(SubscriptionPlan.silver().name).toBe('Silver');
expect(plan.monthlyPriceEur).toBe(49);
expect(plan.yearlyPriceEur).toBe(470);
}); });
it('should return features for PRO plan', () => { it('should return correct prices for SILVER plan', () => {
const plan = SubscriptionPlan.pro(); const plan = SubscriptionPlan.silver();
expect(plan.features).toContain('Up to 20 users'); expect(plan.monthlyPriceEur).toBe(249);
expect(plan.features).toContain('API access'); expect(plan.yearlyPriceEur).toBe(2739);
});
it('should return features for GOLD plan', () => {
const plan = SubscriptionPlan.gold();
expect(plan.features).toContain("Jusqu'à 20 utilisateurs");
expect(plan.features).toContain('Intégration API');
}); });
}); });
@ -200,24 +249,24 @@ describe('SubscriptionPlan Value Object', () => {
const plans = SubscriptionPlan.getAllPlans(); const plans = SubscriptionPlan.getAllPlans();
expect(plans).toHaveLength(4); expect(plans).toHaveLength(4);
expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']); expect(plans.map(p => p.value)).toEqual(['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']);
}); });
}); });
describe('equals', () => { describe('equals', () => {
it('should return true for same plan', () => { it('should return true for same plan', () => {
expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true); expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.bronze())).toBe(true);
}); });
it('should return false for different plans', () => { it('should return false for different plans', () => {
expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false); expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.silver())).toBe(false);
}); });
}); });
describe('toString', () => { describe('toString', () => {
it('should return plan value as string', () => { it('should return plan value as string', () => {
expect(SubscriptionPlan.free().toString()).toBe('FREE'); expect(SubscriptionPlan.bronze().toString()).toBe('BRONZE');
expect(SubscriptionPlan.starter().toString()).toBe('STARTER'); expect(SubscriptionPlan.silver().toString()).toBe('SILVER');
}); });
}); });
}); });

View File

@ -5,25 +5,24 @@
* Each plan has a maximum number of licenses, shipment limits, commission rates, * Each plan has a maximum number of licenses, shipment limits, commission rates,
* feature flags, and support levels. * feature flags, and support levels.
* *
* Plans: FREE (0EUR/mo), STARTER (49EUR/mo), PRO (249EUR/mo), ENTERPRISE (custom) * Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
*/ */
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam'; export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam';
export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium'; export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium';
/** /**
* Legacy plan name mapping for backward compatibility with DB values. * Legacy plan name mapping for backward compatibility during migration.
* DB stores BRONZE/SILVER/GOLD/PLATINIUM (from migration); map them to canonical names.
*/ */
const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = { const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = {
BRONZE: 'FREE', FREE: 'BRONZE',
SILVER: 'STARTER', STARTER: 'SILVER',
GOLD: 'PRO', PRO: 'GOLD',
PLATINIUM: 'ENTERPRISE', ENTERPRISE: 'PLATINIUM',
}; };
interface PlanDetails { interface PlanDetails {
@ -40,58 +39,58 @@ interface PlanDetails {
} }
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = { const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
FREE: { BRONZE: {
name: 'Free', name: 'Bronze',
maxLicenses: 2, maxLicenses: 1,
monthlyPriceEur: 0, monthlyPriceEur: 0,
yearlyPriceEur: 0, yearlyPriceEur: 0,
maxShipmentsPerYear: 12, maxShipmentsPerYear: 12,
commissionRatePercent: 5, commissionRatePercent: 5,
statusBadge: 'none', statusBadge: 'none',
supportLevel: 'none', supportLevel: 'none',
planFeatures: PLAN_FEATURES.FREE, planFeatures: PLAN_FEATURES.BRONZE,
features: ['Up to 2 users', '12 shipments per year', 'Basic rate search'], features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
}, },
STARTER: { SILVER: {
name: 'Starter', name: 'Silver',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 49, monthlyPriceEur: 249,
yearlyPriceEur: 470, yearlyPriceEur: 2739,
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 3, commissionRatePercent: 3,
statusBadge: 'silver', statusBadge: 'silver',
supportLevel: 'email', supportLevel: 'email',
planFeatures: PLAN_FEATURES.STARTER, planFeatures: PLAN_FEATURES.SILVER,
features: [ features: [
'Up to 5 users', "Jusqu'à 5 utilisateurs",
'Unlimited shipments', 'Expéditions illimitées',
'Dashboard', 'Tableau de bord',
'Maritime Wiki', 'Wiki Maritime',
'User management', 'Gestion des utilisateurs',
'CSV import', 'Import CSV',
'Email support', 'Support par email',
], ],
}, },
PRO: { GOLD: {
name: 'Pro', name: 'Gold',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 249, monthlyPriceEur: 899,
yearlyPriceEur: 2739, yearlyPriceEur: 9889,
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 2, commissionRatePercent: 2,
statusBadge: 'gold', statusBadge: 'gold',
supportLevel: 'direct', supportLevel: 'direct',
planFeatures: PLAN_FEATURES.PRO, planFeatures: PLAN_FEATURES.GOLD,
features: [ features: [
'Up to 20 users', "Jusqu'à 20 utilisateurs",
'Unlimited shipments', 'Expéditions illimitées',
'All Starter features', 'Toutes les fonctionnalités Silver',
'API access', 'Intégration API',
'Direct commercial support', 'Assistance commerciale directe',
], ],
}, },
ENTERPRISE: { PLATINIUM: {
name: 'Enterprise', name: 'Platinium',
maxLicenses: -1, // unlimited maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing
@ -99,13 +98,13 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
commissionRatePercent: 1, commissionRatePercent: 1,
statusBadge: 'platinium', statusBadge: 'platinium',
supportLevel: 'dedicated_kam', supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.ENTERPRISE, planFeatures: PLAN_FEATURES.PLATINIUM,
features: [ features: [
'Unlimited users', 'Utilisateurs illimités',
'All Pro features', 'Toutes les fonctionnalités Gold',
'Dedicated Key Account Manager', 'Key Account Manager dédié',
'Custom interface', 'Interface personnalisable',
'Framework rate contracts', 'Contrats tarifaires cadre',
], ],
}, },
}; };
@ -122,18 +121,18 @@ export class SubscriptionPlan {
/** /**
* Create from string with legacy name support. * Create from string with legacy name support.
* Accepts both old DB names (BRONZE/SILVER/GOLD/PLATINIUM) and canonical names (FREE/STARTER/PRO/ENTERPRISE). * Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names.
*/ */
static fromString(value: string): SubscriptionPlan { static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase(); const upperValue = value.toUpperCase();
// Check legacy mapping first (DB values BRONZE/SILVER/GOLD/PLATINIUM) // Check legacy mapping first
const mapped = LEGACY_PLAN_MAPPING[upperValue]; const mapped = LEGACY_PLAN_MAPPING[upperValue];
if (mapped) { if (mapped) {
return new SubscriptionPlan(mapped); return new SubscriptionPlan(mapped);
} }
// Try direct match (canonical names) // Try direct match
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) { if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
return new SubscriptionPlan(upperValue as SubscriptionPlanType); return new SubscriptionPlan(upperValue as SubscriptionPlanType);
} }
@ -142,41 +141,41 @@ export class SubscriptionPlan {
} }
// Named factories // Named factories
static free(): SubscriptionPlan {
return new SubscriptionPlan('FREE');
}
static starter(): SubscriptionPlan {
return new SubscriptionPlan('STARTER');
}
static pro(): SubscriptionPlan {
return new SubscriptionPlan('PRO');
}
static enterprise(): SubscriptionPlan {
return new SubscriptionPlan('ENTERPRISE');
}
// Legacy aliases (kept for backward compatibility)
static bronze(): SubscriptionPlan { static bronze(): SubscriptionPlan {
return SubscriptionPlan.free(); return new SubscriptionPlan('BRONZE');
} }
static silver(): SubscriptionPlan { static silver(): SubscriptionPlan {
return SubscriptionPlan.starter(); return new SubscriptionPlan('SILVER');
} }
static gold(): SubscriptionPlan { static gold(): SubscriptionPlan {
return SubscriptionPlan.pro(); return new SubscriptionPlan('GOLD');
} }
static platinium(): SubscriptionPlan { static platinium(): SubscriptionPlan {
return SubscriptionPlan.enterprise(); return new SubscriptionPlan('PLATINIUM');
}
// Legacy aliases
static free(): SubscriptionPlan {
return SubscriptionPlan.bronze();
}
static starter(): SubscriptionPlan {
return SubscriptionPlan.silver();
}
static pro(): SubscriptionPlan {
return SubscriptionPlan.gold();
}
static enterprise(): SubscriptionPlan {
return SubscriptionPlan.platinium();
} }
static getAllPlans(): SubscriptionPlan[] { static getAllPlans(): SubscriptionPlan[] {
return (['FREE', 'STARTER', 'PRO', 'ENTERPRISE'] as SubscriptionPlanType[]).map( return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map(
p => new SubscriptionPlan(p) p => new SubscriptionPlan(p)
); );
} }
@ -226,75 +225,48 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].planFeatures; return PLAN_DETAILS[this.plan].planFeatures;
} }
/**
* Check if this plan includes a specific feature
*/
hasFeature(feature: PlanFeature): boolean { hasFeature(feature: PlanFeature): boolean {
return this.planFeatures.includes(feature); return this.planFeatures.includes(feature);
} }
/**
* Returns true if this plan has unlimited licenses
*/
isUnlimited(): boolean { isUnlimited(): boolean {
return this.maxLicenses === -1; return this.maxLicenses === -1;
} }
/**
* Returns true if this plan has unlimited shipments
*/
hasUnlimitedShipments(): boolean { hasUnlimitedShipments(): boolean {
return this.maxShipmentsPerYear === -1; return this.maxShipmentsPerYear === -1;
} }
/**
* Returns true if this is a paid plan
*/
isPaid(): boolean { isPaid(): boolean {
return this.plan !== 'FREE'; return this.plan !== 'BRONZE';
} }
/**
* Returns true if this is the free plan
*/
isFree(): boolean { isFree(): boolean {
return this.plan === 'FREE'; return this.plan === 'BRONZE';
} }
/**
* Returns true if this plan has custom pricing (Enterprise)
*/
isCustomPricing(): boolean { isCustomPricing(): boolean {
return this.plan === 'ENTERPRISE'; return this.plan === 'PLATINIUM';
} }
/**
* Check if a given number of users can be accommodated by this plan
*/
canAccommodateUsers(userCount: number): boolean { canAccommodateUsers(userCount: number): boolean {
if (this.isUnlimited()) return true; if (this.isUnlimited()) return true;
return userCount <= this.maxLicenses; return userCount <= this.maxLicenses;
} }
/**
* Check if upgrade to target plan is allowed
*/
canUpgradeTo(targetPlan: SubscriptionPlan): boolean { canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex; return targetIndex > currentIndex;
} }
/**
* Check if downgrade to target plan is allowed given current user count
*/
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);
if (targetIndex >= currentIndex) return false; // Not a downgrade if (targetIndex >= currentIndex) return false;
return targetPlan.canAccommodateUsers(currentUserCount); return targetPlan.canAccommodateUsers(currentUserCount);
} }

View File

@ -51,22 +51,22 @@ export class StripeAdapter implements StripePort {
const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID'); const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID');
const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID'); const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID');
if (silverMonthly) this.priceIdMap.set(silverMonthly, 'STARTER'); if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER');
if (silverYearly) this.priceIdMap.set(silverYearly, 'STARTER'); if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER');
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'PRO'); if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD');
if (goldYearly) this.priceIdMap.set(goldYearly, 'PRO'); if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD');
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'ENTERPRISE'); if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM');
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'ENTERPRISE'); if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM');
this.planPriceMap.set('STARTER', { this.planPriceMap.set('SILVER', {
monthly: silverMonthly || '', monthly: silverMonthly || '',
yearly: silverYearly || '', yearly: silverYearly || '',
}); });
this.planPriceMap.set('PRO', { this.planPriceMap.set('GOLD', {
monthly: goldMonthly || '', monthly: goldMonthly || '',
yearly: goldYearly || '', yearly: goldYearly || '',
}); });
this.planPriceMap.set('ENTERPRISE', { this.planPriceMap.set('PLATINIUM', {
monthly: platiniumMonthly || '', monthly: platiniumMonthly || '',
yearly: platiniumYearly || '', yearly: platiniumYearly || '',
}); });

View File

@ -11,9 +11,9 @@ import {
Bug, Bug,
Server, Server,
} from 'lucide-react'; } from 'lucide-react';
import { get, download } from '@/lib/api/client';
const LOG_EXPORTER_URL = const LOGS_PREFIX = '/api/v1/logs';
process.env.NEXT_PUBLIC_LOG_EXPORTER_URL || 'http://localhost:3200';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -125,8 +125,7 @@ export default function AdminLogsPage() {
// Load available services // Load available services
useEffect(() => { useEffect(() => {
fetch(`${LOG_EXPORTER_URL}/api/logs/services`) get<{ services: string[] }>(`${LOGS_PREFIX}/services`)
.then(r => r.json())
.then(d => setServices(d.services || [])) .then(d => setServices(d.services || []))
.catch(() => {}); .catch(() => {});
}, []); }, []);
@ -150,14 +149,9 @@ export default function AdminLogsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetch( const data = await get<LogsResponse>(
`${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString('json')}`, `${LOGS_PREFIX}/export?${buildQueryString('json')}`,
); );
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const data: LogsResponse = await res.json();
setLogs(data.logs || []); setLogs(data.logs || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} catch (err: any) { } catch (err: any) {
@ -174,19 +168,11 @@ export default function AdminLogsPage() {
const handleExport = async (format: 'json' | 'csv') => { const handleExport = async (format: 'json' | 'csv') => {
setExportLoading(true); setExportLoading(true);
try { try {
const res = await fetch( const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
`${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString(format)}`, await download(
`${LOGS_PREFIX}/export?${buildQueryString(format)}`,
filename,
); );
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally { } finally {
@ -384,8 +370,7 @@ export default function AdminLogsPage() {
Impossible de contacter le log-exporter : <strong>{error}</strong> Impossible de contacter le log-exporter : <strong>{error}</strong>
<br /> <br />
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">
Vérifiez que le container log-exporter est démarré sur{' '} Vérifiez que le backend et le log-exporter sont démarrés.
<code className="font-mono">{LOG_EXPORTER_URL}</code>
</span> </span>
</span> </span>
</div> </div>

View File

@ -1,32 +1,37 @@
{ {
"title": "Xpeditis — Logs & Monitoring", "__inputs": [
"uid": "xpeditis-logs", {
"description": "Dashboard complet — logs backend/frontend, métriques HTTP, erreurs", "name": "DS_LOKI",
"tags": ["xpeditis", "logs", "backend", "frontend"], "label": "Loki",
"timezone": "browser", "description": "Loki datasource",
"type": "datasource",
"pluginId": "loki",
"pluginName": "Loki"
}
],
"__requires": [
{ "type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.0.0" },
{ "type": "datasource", "id": "loki", "name": "Loki", "version": "1.0.0" },
{ "type": "panel", "id": "stat", "name": "Stat", "version": "" },
{ "type": "panel", "id": "timeseries", "name": "Time series", "version": "" },
{ "type": "panel", "id": "piechart", "name": "Pie chart", "version": "" },
{ "type": "panel", "id": "bargauge", "name": "Bar gauge", "version": "" },
{ "type": "panel", "id": "logs", "name": "Logs", "version": "" }
],
"title": "Xpeditis — Logs & KPIs",
"uid": "xpeditis-logs-kpis",
"description": "Logs applicatifs, KPIs HTTP, temps de réponse et erreurs — Backend & Frontend",
"tags": ["xpeditis", "logs", "monitoring", "backend"],
"timezone": "Europe/Paris",
"refresh": "30s", "refresh": "30s",
"schemaVersion": 38, "schemaVersion": 39,
"time": { "from": "now-1h", "to": "now" }, "time": { "from": "now-1h", "to": "now" },
"timepicker": {}, "timepicker": {},
"fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
"editable": true, "editable": true,
"version": 1, "version": 1,
"weekStart": "",
"links": [], "links": [],
"annotations": { "annotations": { "list": [] },
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0,211,255,1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"templating": { "templating": {
"list": [ "list": [
@ -34,119 +39,99 @@
"name": "service", "name": "service",
"label": "Service", "label": "Service",
"type": "query", "type": "query",
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"query": "label_values(service)", "query": "label_values(service)",
"refresh": 2, "refresh": 2,
"sort": 1,
"includeAll": true, "includeAll": true,
"allValue": ".+", "allValue": ".+",
"multi": false, "multi": true,
"hide": 0,
"current": {}, "current": {},
"options": [] "hide": 0,
"sort": 1
}, },
{ {
"name": "level", "name": "level",
"label": "Niveau", "label": "Niveau",
"type": "custom", "type": "query",
"query": "All : .+, error : error, fatal : fatal, warn : warn, info : info, debug : debug", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"includeAll": false, "query": "label_values(level)",
"multi": false, "refresh": 2,
"includeAll": true,
"allValue": ".+",
"multi": true,
"current": {},
"hide": 0, "hide": 0,
"current": { "text": "All", "value": ".+" }, "sort": 1
"options": [
{ "text": "All", "value": ".+", "selected": true },
{ "text": "error", "value": "error", "selected": false },
{ "text": "fatal", "value": "fatal", "selected": false },
{ "text": "warn", "value": "warn", "selected": false },
{ "text": "info", "value": "info", "selected": false },
{ "text": "debug", "value": "debug", "selected": false }
]
},
{
"name": "search",
"label": "Recherche",
"type": "textbox",
"query": "",
"hide": 0,
"current": { "text": "", "value": "" },
"options": [{ "selected": true, "text": "", "value": "" }]
} }
] ]
}, },
"panels": [ "panels": [
{
"id": 100,
"type": "row",
"title": "Vue d'ensemble",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
},
{ {
"id": 1, "id": 1,
"title": "Total logs", "title": "Requêtes totales",
"type": "stat", "type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
"orientation": "auto", "orientation": "auto",
"textMode": "auto", "textMode": "auto",
"colorMode": "background", "colorMode": "background",
"graphMode": "area", "graphMode": "none",
"justifyMode": "center" "justifyMode": "center"
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "thresholds" }, "color": { "mode": "fixed", "fixedColor": "#10183A" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, "unit": "short",
"mappings": [] "thresholds": { "mode": "absolute", "steps": [{ "color": "#10183A", "value": null }] }
}, },
"overrides": [] "overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "sum(count_over_time({service=~\"$service\"} | json | req_method != \"\" [$__range]))",
"expr": "sum(count_over_time({service=~\"$service\"} [$__range]))", "legendFormat": "Requêtes",
"legendFormat": "Total", "instant": true,
"instant": true "range": false,
"refId": "A"
} }
] ]
}, },
{ {
"id": 2, "id": 2,
"title": "Erreurs & Fatal", "title": "Erreurs (error + fatal)",
"type": "stat", "type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
"orientation": "auto", "orientation": "auto",
"textMode": "auto", "textMode": "auto",
"colorMode": "background", "colorMode": "background",
"graphMode": "area", "graphMode": "none",
"justifyMode": "center" "justifyMode": "center"
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "thresholds" }, "color": { "mode": "fixed", "fixedColor": "red" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, "unit": "short",
"mappings": [] "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] }
}, },
"overrides": [] "overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))", "expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))",
"legendFormat": "Erreurs", "legendFormat": "Erreurs",
"instant": true "instant": true,
"range": false,
"refId": "A"
} }
] ]
}, },
@ -155,342 +140,342 @@
"id": 3, "id": 3,
"title": "Warnings", "title": "Warnings",
"type": "stat", "type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
"orientation": "auto", "orientation": "auto",
"textMode": "auto", "textMode": "auto",
"colorMode": "background", "colorMode": "background",
"graphMode": "area", "graphMode": "none",
"justifyMode": "center" "justifyMode": "center"
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "thresholds" }, "color": { "mode": "fixed", "fixedColor": "orange" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] }, "unit": "short",
"mappings": [] "thresholds": { "mode": "absolute", "steps": [{ "color": "orange", "value": null }] }
}, },
"overrides": [] "overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))", "expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))",
"legendFormat": "Warnings", "legendFormat": "Warnings",
"instant": true "instant": true,
"range": false,
"refId": "A"
} }
] ]
}, },
{ {
"id": 4, "id": 4,
"title": "Info", "title": "Taux d'erreur",
"type": "stat", "type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto", "orientation": "auto",
"textMode": "auto", "textMode": "auto",
"colorMode": "background", "colorMode": "background",
"graphMode": "area", "graphMode": "none",
"justifyMode": "center" "justifyMode": "center"
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" }, "unit": "percentunit",
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, "thresholds": {
"mappings": [] "mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"color": { "mode": "thresholds" }
}, },
"overrides": [] "overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "sum(rate({service=~\"$service\", level=~\"error|fatal\"} [$__interval])) / sum(rate({service=~\"$service\"} [$__interval]))",
"expr": "sum(count_over_time({service=~\"$service\", level=\"info\"} [$__range]))", "legendFormat": "Taux d'erreur",
"legendFormat": "Info", "instant": false,
"instant": true "range": true,
"refId": "A"
} }
] ]
}, },
{ {
"id": 5, "id": 5,
"title": "Requêtes HTTP 5xx", "title": "Trafic par service (req/s)",
"type": "stat", "type": "timeseries",
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "tooltip": { "mode": "multi", "sort": "desc" },
"orientation": "auto", "legend": { "displayMode": "list", "placement": "bottom" }
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "thresholds" }, "unit": "reqps",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, "color": { "mode": "palette-classic" },
"mappings": [] "custom": {
"lineWidth": 2,
"fillOpacity": 10,
"gradientMode": "opacity",
"spanNulls": false
}
}, },
"overrides": [] "overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "sum by(service) (rate({service=~\"$service\"} | json | req_method != \"\" [$__interval]))",
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__range]))", "legendFormat": "{{service}}",
"legendFormat": "5xx", "instant": false,
"instant": true "range": true,
"refId": "A"
} }
] ]
}, },
{ {
"id": 6, "id": 6,
"title": "Temps réponse moyen (ms)", "title": "Erreurs & Warnings dans le temps",
"type": "stat", "type": "timeseries",
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "tooltip": { "mode": "multi", "sort": "desc" },
"orientation": "auto", "legend": { "displayMode": "list", "placement": "bottom" }
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "thresholds" }, "unit": "short",
"unit": "ms", "color": { "mode": "palette-classic" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }, "custom": {
"mappings": [] "lineWidth": 2,
"fillOpacity": 15,
"gradientMode": "opacity"
}
}, },
"overrides": [] "overrides": [
{
"matcher": { "id": "byName", "options": "error" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
},
{
"matcher": { "id": "byName", "options": "fatal" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }]
},
{
"matcher": { "id": "byName", "options": "warn" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }]
}
]
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "sum by(level) (rate({service=~\"$service\", level=~\"error|fatal|warn\"} [$__interval]))",
"expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__range]))", "legendFormat": "{{level}}",
"legendFormat": "Avg", "instant": false,
"instant": true "range": true,
"refId": "A"
} }
] ]
}, },
{
"id": 200,
"type": "row",
"title": "Volume des logs",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }
},
{ {
"id": 7, "id": 7,
"title": "Volume par niveau", "title": "Temps de réponse Backend",
"type": "timeseries", "type": "timeseries",
"gridPos": { "h": 8, "w": 14, "x": 0, "y": 6 }, "gridPos": { "x": 0, "y": 12, "w": 16, "h": 8 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" },
"tooltip": { "mode": "multi", "sort": "desc" } "legend": { "displayMode": "list", "placement": "bottom" }
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"unit": "ms",
"color": { "mode": "palette-classic" }, "color": { "mode": "palette-classic" },
"custom": { "custom": {
"drawStyle": "bars", "lineWidth": 2,
"fillOpacity": 80, "fillOpacity": 8,
"stacking": { "group": "A", "mode": "normal" }, "gradientMode": "opacity"
"lineWidth": 1,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false
}, },
"unit": "short", "thresholds": {
"mappings": [], "mode": "absolute",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } "steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 500 },
{ "color": "red", "value": 1000 }
]
}
}, },
"overrides": [ "overrides": [
{ "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, {
{ "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }] }, "matcher": { "id": "byName", "options": "Pire cas (1% des requêtes)" },
{ "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
{ "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, },
{ "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "fixedColor": "gray", "mode": "fixed" } }] } {
"matcher": { "id": "byName", "options": "Lent (5% des requêtes)" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }]
},
{
"matcher": { "id": "byName", "options": "Temps médian (requête typique)" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }]
}
] ]
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "quantile_over_time(0.50, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])",
"expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\".+\"} [$__interval]))", "legendFormat": "Temps médian (requête typique)",
"legendFormat": "{{level}}" "instant": false,
"range": true,
"refId": "A"
},
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "quantile_over_time(0.95, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])",
"legendFormat": "Lent (5% des requêtes)",
"instant": false,
"range": true,
"refId": "B"
},
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "quantile_over_time(0.99, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])",
"legendFormat": "Pire cas (1% des requêtes)",
"instant": false,
"range": true,
"refId": "C"
} }
] ]
}, },
{ {
"id": 8, "id": 8,
"title": "Volume par service", "title": "Répartition par niveau de log",
"type": "timeseries", "type": "piechart",
"gridPos": { "h": 8, "w": 10, "x": 14, "y": 6 }, "gridPos": { "x": 16, "y": 12, "w": 8, "h": 8 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, "pieType": "donut",
"tooltip": { "mode": "multi", "sort": "desc" } "tooltip": { "mode": "single" },
"legend": { "displayMode": "list", "placement": "bottom", "values": ["percent"] }
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } },
"color": { "mode": "palette-classic" }, "overrides": [
"custom": { { "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] },
"drawStyle": "bars", { "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }] },
"fillOpacity": 60, { "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] },
"stacking": { "group": "A", "mode": "normal" }, { "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }] },
"lineWidth": 1, { "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] }
"showPoints": "never", ]
"spanNulls": false
},
"unit": "short",
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "sum by(level) (count_over_time({service=~\"$service\", level=~\"$level\"} [$__range]))",
"expr": "sum by (service) (count_over_time({service=~\"$service\"} [$__interval]))", "legendFormat": "{{level}}",
"legendFormat": "{{service}}" "instant": true,
"range": false,
"refId": "A"
} }
] ]
}, },
{
"id": 300,
"type": "row",
"title": "HTTP — Backend",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }
},
{ {
"id": 9, "id": 9,
"title": "Taux d'erreur HTTP", "title": "Codes HTTP (5m)",
"type": "timeseries", "type": "bargauge",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, "gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, "orientation": "horizontal",
"tooltip": { "mode": "multi", "sort": "desc" } "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"displayMode": "gradient",
"valueMode": "color",
"showUnfilled": true,
"minVizWidth": 10,
"minVizHeight": 10
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "line",
"fillOpacity": 20,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false
},
"unit": "short", "unit": "short",
"mappings": [], "color": { "mode": "palette-classic" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } "thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 1 }
]
}
}, },
"overrides": [ "overrides": []
{ "matcher": { "id": "byName", "options": "5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
]
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "sum by(status_code) (count_over_time({service=\"backend\"} | json | res_statusCode != \"\" | label_format status_code=\"{{res_statusCode}}\" [$__range]))",
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__interval]))", "legendFormat": "HTTP {{status_code}}",
"legendFormat": "5xx" "instant": true,
}, "range": false,
{ "refId": "A"
"refId": "B",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 400 < 500 [$__interval]))",
"legendFormat": "4xx"
},
{
"refId": "C",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 200 < 300 [$__interval]))",
"legendFormat": "2xx"
} }
] ]
}, },
{ {
"id": 10, "id": 10,
"title": "Temps de réponse (ms)", "title": "Top erreurs par contexte NestJS",
"type": "timeseries", "type": "bargauge",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, "gridPos": { "x": 12, "y": 20, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, "orientation": "horizontal",
"tooltip": { "mode": "multi", "sort": "desc" } "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"displayMode": "gradient",
"showUnfilled": true
}, },
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "mode": "palette-classic" }, "unit": "short",
"custom": { "color": { "mode": "fixed", "fixedColor": "red" },
"drawStyle": "line", "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] }
"fillOpacity": 10,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false
},
"unit": "ms",
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }
}, },
"overrides": [] "overrides": []
}, },
"targets": [ "targets": [
{ {
"refId": "A", "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "expr": "topk(10, sum by(context) (count_over_time({service=\"backend\", level=~\"error|fatal\"} | json | context != \"\" [$__range]) ))",
"expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", "legendFormat": "{{context}}",
"legendFormat": "Moy" "instant": true,
}, "range": false,
{ "refId": "A"
"refId": "B",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "max(max_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))",
"legendFormat": "Max"
} }
] ]
}, },
{
"id": 400,
"type": "row",
"title": "Logs — Flux en direct",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }
},
{ {
"id": 11, "id": 11,
"title": "Backend — Logs", "title": "Logs — Backend",
"type": "logs", "type": "logs",
"gridPos": { "h": 14, "w": 12, "x": 0, "y": 24 }, "gridPos": { "x": 0, "y": 28, "w": 24, "h": 12 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"dedupStrategy": "none", "dedupStrategy": "none",
"enableLogDetails": true, "enableLogDetails": true,
@ -503,24 +488,27 @@
}, },
"targets": [ "targets": [
{ {
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "{service=\"backend\", level=~\"$level\"}",
"legendFormat": "",
"instant": false,
"range": true,
"refId": "A", "refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "maxLines": 500
"expr": "{service=\"backend\", level=~\"$level\"} |= \"$search\"",
"legendFormat": ""
} }
] ]
}, },
{ {
"id": 12, "id": 12,
"title": "Frontend — Logs", "title": "Logs — Frontend",
"type": "logs", "type": "logs",
"gridPos": { "h": 14, "w": 12, "x": 12, "y": 24 }, "gridPos": { "x": 0, "y": 40, "w": 24, "h": 10 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": { "options": {
"dedupStrategy": "none", "dedupStrategy": "none",
"enableLogDetails": true, "enableLogDetails": true,
"prettifyLogMessage": true, "prettifyLogMessage": false,
"showCommonLabels": false, "showCommonLabels": false,
"showLabels": false, "showLabels": false,
"showTime": true, "showTime": true,
@ -529,105 +517,13 @@
}, },
"targets": [ "targets": [
{ {
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "{service=\"frontend\"}",
"legendFormat": "",
"instant": false,
"range": true,
"refId": "A", "refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" }, "maxLines": 200
"expr": "{service=\"frontend\", level=~\"$level\"} |= \"$search\"",
"legendFormat": ""
}
]
},
{
"id": 500,
"type": "row",
"title": "Tous les logs filtrés",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 }
},
{
"id": 13,
"title": "Flux filtré — $service / $level",
"description": "Utilisez les variables en haut pour filtrer par service, niveau ou mot-clé",
"type": "logs",
"gridPos": { "h": 14, "w": 24, "x": 0, "y": 39 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=~\"$service\", level=~\"$level\"} |= \"$search\"",
"legendFormat": ""
}
]
},
{
"id": 600,
"type": "row",
"title": "Erreurs & Exceptions",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 }
},
{
"id": 14,
"title": "Erreurs — Backend",
"type": "logs",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 54 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "signature",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=\"backend\", level=~\"error|fatal\"}",
"legendFormat": ""
}
]
},
{
"id": 15,
"title": "Erreurs — Frontend",
"type": "logs",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 54 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "signature",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=\"frontend\", level=~\"error|fatal\"}",
"legendFormat": ""
} }
] ]
} }

View File

@ -5,7 +5,7 @@ datasources:
uid: loki-xpeditis uid: loki-xpeditis
type: loki type: loki
access: proxy access: proxy
url: http://loki:3100 url: http://xpeditis-loki:3100
isDefault: true isDefault: true
version: 1 version: 1
editable: false editable: false

View File

@ -1,53 +1,43 @@
server: server:
http_listen_port: 9080 http_listen_port: 9080
grpc_listen_port: 0
log_level: warn log_level: warn
positions: positions:
filename: /tmp/positions.yaml filename: /tmp/positions.yaml
clients: clients:
- url: http://loki:3100/loki/api/v1/push - url: http://xpeditis-loki:3100/loki/api/v1/push
batchwait: 1s batchwait: 1s
batchsize: 1048576 batchsize: 1048576
timeout: 10s timeout: 10s
scrape_configs: scrape_configs:
# ─── Docker container log collection (Mac-compatible via Docker socket API) ─
- job_name: docker - job_name: docker
docker_sd_configs: docker_sd_configs:
- host: unix:///var/run/docker.sock - host: unix:///var/run/docker.sock
refresh_interval: 5s refresh_interval: 5s
filters: filters:
# Only collect containers with label: logging=promtail
# Add this label to backend and frontend in docker-compose.dev.yml
- name: label - name: label
values: ['logging=promtail'] values: ['logging=promtail']
relabel_configs: relabel_configs:
# Use docker-compose service name as the "service" label - source_labels: ['__meta_docker_container_label_logging_service']
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service target_label: service
# Keep container name for context
- source_labels: ['__meta_docker_container_name'] - source_labels: ['__meta_docker_container_name']
regex: '/?(.*)' regex: '/?(.*)'
replacement: '${1}' replacement: '${1}'
target_label: container target_label: container
# Log stream (stdout / stderr)
- source_labels: ['__meta_docker_container_log_stream'] - source_labels: ['__meta_docker_container_log_stream']
target_label: stream target_label: stream
pipeline_stages: pipeline_stages:
# Drop entries older than 15 min to avoid replaying full container log history
- drop: - drop:
older_than: 15m older_than: 15m
drop_counter_reason: entry_too_old drop_counter_reason: entry_too_old
# Drop noisy health-check / ping lines
- drop: - drop:
expression: 'GET /(health|metrics|minio/health)' expression: 'GET /(health|metrics|minio/health)'
# Try to parse JSON (NestJS/pino output)
- json: - json:
expressions: expressions:
level: level level: level
@ -55,12 +45,10 @@ scrape_configs:
context: context context: context
reqId: reqId reqId: reqId
# Promote parsed fields as Loki labels
- labels: - labels:
level: level:
context: context:
# Map pino numeric levels to strings
- template: - template:
source: level source: level
template: >- template: >-