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
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>
This commit is contained in:
commit
be1de882c3
42
.github/workflows/cd-main.yml
vendored
42
.github/workflows/cd-main.yml
vendored
@ -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: |
|
||||||
|
|||||||
82
.github/workflows/cd-preprod.yml
vendored
82
.github/workflows/cd-preprod.yml
vendored
@ -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
2
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
98
apps/backend/src/application/logs/logs.controller.ts
Normal file
98
apps/backend/src/application/logs/logs.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/application/logs/logs.module.ts
Normal file
9
apps/backend/src/application/logs/logs.module.ts
Normal 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 {}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 || '',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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": ""
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: >-
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user