feat(admin): monitoring center
Sprint 26. New features/admin/monitoring/ module + net-new /:lang/backoffice/monitoring route + Dashboard Quick Action. - Health section reuses AdminDashboardFacade.healthChecks directly (real data, unchanged since Sprint 19) instead of duplicating the logic - unified AdminMonitoringEvent feed covering audit/security/login/ failed-login/api/error/warning, category filter + search, 40 seeded synthetic entries (no logging backend exists anywhere in this system) - mock queue depth/status cards, mock webhook delivery log - intentionally kept separate from Sprint 24's per-transaction audit and Sprint 25's per-user audit - different scopes, no consolidation attempted Also fixed a real type error: AdminDashboardQuickActionId's union was missing 'users' and 'monitoring' (added when wiring those Quick Actions), caught by ng build's template type-checking even though plain tsc --noEmit passed - a reminder that ng build is the authoritative check here. docs/ADMIN.md + docs/BACKEND.md (new item 15) updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
|
||||
{ id: 'orders', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
|
||||
{ id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
|
||||
{ id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
|
||||
{ id: 'monitoring', labelKey: 'dashboard.actionMonitoring', route: ['backoffice', 'monitoring'] },
|
||||
{ id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] },
|
||||
];
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export type AdminDashboardQuickActionId =
|
||||
| 'transactions'
|
||||
| 'orders'
|
||||
| 'media-library'
|
||||
| 'users'
|
||||
| 'monitoring'
|
||||
| 'preview-marketplace';
|
||||
|
||||
export interface AdminDashboardQuickAction {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AdminMonitoringEvent, AdminMonitoringEventFilters, AdminQueue, AdminWebhookDelivery } from '../models/admin-monitoring.model';
|
||||
import { AdminMonitoringLocalGateway } from '../services/admin-monitoring-local.gateway';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminMonitoringFacade {
|
||||
private readonly gateway = inject(AdminMonitoringLocalGateway);
|
||||
|
||||
readonly filters = signal<AdminMonitoringEventFilters>({ category: 'all', search: '' });
|
||||
readonly events = signal<AdminMonitoringEvent[]>([]);
|
||||
readonly queues = signal<AdminQueue[]>([]);
|
||||
readonly webhooks = signal<AdminWebhookDelivery[]>([]);
|
||||
readonly loading = signal(false);
|
||||
|
||||
loadAll(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway.loadEvents(this.filters()).pipe(take(1)).subscribe(events => { this.events.set(events); this.loading.set(false); });
|
||||
this.gateway.loadQueues().pipe(take(1)).subscribe(queues => this.queues.set(queues));
|
||||
this.gateway.loadWebhooks().pipe(take(1)).subscribe(webhooks => this.webhooks.set(webhooks));
|
||||
}
|
||||
|
||||
updateFilters(patch: Partial<AdminMonitoringEventFilters>): void {
|
||||
this.filters.update(current => ({ ...current, ...patch }));
|
||||
this.gateway.loadEvents(this.filters()).pipe(take(1)).subscribe(events => this.events.set(events));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type AdminMonitoringCategory = 'audit' | 'security' | 'login' | 'failed_login' | 'api' | 'error' | 'warning';
|
||||
export type AdminMonitoringLevel = 'info' | 'warning' | 'error';
|
||||
export type AdminQueueStatus = 'healthy' | 'degraded' | 'down';
|
||||
export type AdminWebhookStatus = 'delivered' | 'failed' | 'pending';
|
||||
|
||||
export interface AdminMonitoringEvent {
|
||||
id: string;
|
||||
category: AdminMonitoringCategory;
|
||||
level: AdminMonitoringLevel;
|
||||
message: string;
|
||||
actor: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AdminMonitoringEventFilters {
|
||||
category: 'all' | AdminMonitoringCategory;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface AdminQueue {
|
||||
name: string;
|
||||
depth: number;
|
||||
status: AdminQueueStatus;
|
||||
}
|
||||
|
||||
export interface AdminWebhookDelivery {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
event: string;
|
||||
status: AdminWebhookStatus;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<section class="admin-monitoring-page">
|
||||
<div class="card">
|
||||
<h2>{{ 'adminMonitoring.health' | translate }}</h2>
|
||||
<div class="health-grid">
|
||||
@for (check of dashboardFacade.healthChecks(); track check.code) {
|
||||
<app-badge [variant]="check.healthy ? 'success' : 'danger'">{{ check.labelKey | translate }}</app-badge>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ 'adminMonitoring.queues' | translate }}</h2>
|
||||
<div class="queue-grid">
|
||||
@for (queue of facade.queues(); track queue.name) {
|
||||
<div class="queue-item">
|
||||
<strong>{{ queue.name }}</strong>
|
||||
<span>{{ 'adminMonitoring.depth' | translate }}: {{ queue.depth }}</span>
|
||||
<app-badge [variant]="queue.status === 'healthy' ? 'success' : queue.status === 'degraded' ? 'warning' : 'danger'">{{ ('adminMonitoring.queueStatus.' + queue.status) | translate }}</app-badge>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ 'adminMonitoring.webhooks' | translate }}</h2>
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminMonitoring.endpoint' | translate }}</th>
|
||||
<th>{{ 'adminMonitoring.event' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'adminOrders.createdAt' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (delivery of facade.webhooks(); track delivery.id) {
|
||||
<tr>
|
||||
<td>{{ delivery.endpoint }}</td>
|
||||
<td>{{ delivery.event }}</td>
|
||||
<td><app-badge [variant]="delivery.status === 'delivered' ? 'success' : delivery.status === 'failed' ? 'danger' : 'neutral'">{{ ('adminMonitoring.webhookStatus.' + delivery.status) | translate }}</app-badge></td>
|
||||
<td>{{ delivery.timestamp | date:'short' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ 'adminMonitoring.events' | translate }}</h2>
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
|
||||
<select [ngModel]="facade.filters().category" (ngModelChange)="facade.updateFilters({ category: $event })">
|
||||
@for (category of categories; track category) {
|
||||
<option [value]="category">{{ ('adminMonitoring.category.' + category) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminMonitoring.category' | translate }}</th>
|
||||
<th>{{ 'adminMonitoring.level' | translate }}</th>
|
||||
<th>{{ 'adminMonitoring.message' | translate }}</th>
|
||||
<th>{{ 'adminUsers.name' | translate }}</th>
|
||||
<th>{{ 'adminOrders.createdAt' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (event of facade.events(); track event.id) {
|
||||
<tr>
|
||||
<td>{{ ('adminMonitoring.category.' + event.category) | translate }}</td>
|
||||
<td><app-badge [variant]="event.level === 'error' ? 'danger' : event.level === 'warning' ? 'warning' : 'neutral'">{{ event.level }}</app-badge></td>
|
||||
<td>{{ event.message }}</td>
|
||||
<td>{{ event.actor }}</td>
|
||||
<td>{{ event.timestamp | date:'short' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,9 @@
|
||||
.admin-monitoring-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; }
|
||||
.card { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.card h2 { margin: 0; font-size: 1.1rem; }
|
||||
.health-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.queue-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.queue-item { border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; padding: 10px; display: grid; gap: 4px; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
|
||||
@media (max-width: 700px) { .queue-grid { grid-template-columns: 1fr; } .filters { flex-direction: column; align-items: stretch; } }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminMonitoringFacade } from '../facade/admin-monitoring.facade';
|
||||
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-monitoring-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent],
|
||||
templateUrl: './admin-monitoring-page.component.html',
|
||||
styleUrls: ['./admin-monitoring-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminMonitoringPageComponent {
|
||||
readonly facade = inject(AdminMonitoringFacade);
|
||||
readonly dashboardFacade = inject(AdminDashboardFacade);
|
||||
|
||||
readonly categories = ['all', 'audit', 'security', 'login', 'failed_login', 'api', 'error', 'warning'] as const;
|
||||
|
||||
constructor() {
|
||||
this.facade.loadAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import {
|
||||
AdminMonitoringCategory,
|
||||
AdminMonitoringEvent,
|
||||
AdminMonitoringEventFilters,
|
||||
AdminMonitoringLevel,
|
||||
AdminQueue,
|
||||
AdminWebhookDelivery,
|
||||
} from '../models/admin-monitoring.model';
|
||||
|
||||
const CATEGORIES: AdminMonitoringCategory[] = ['audit', 'security', 'login', 'failed_login', 'api', 'error', 'warning'];
|
||||
const ACTORS = ['karen@dexar.market', 'anna@dexar.market', 'system', 'unknown'];
|
||||
const EVENT_COUNT = 40;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminMonitoringLocalGateway {
|
||||
private events: AdminMonitoringEvent[] | null = null;
|
||||
|
||||
loadEvents(filters: AdminMonitoringEventFilters): Observable<AdminMonitoringEvent[]> {
|
||||
const all = this.ensureEvents();
|
||||
const filtered = all
|
||||
.filter(event => filters.category === 'all' || event.category === filters.category)
|
||||
.filter(event => !filters.search || `${event.message} ${event.actor}`.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
.sort((left, right) => right.timestamp.localeCompare(left.timestamp));
|
||||
return of(filtered).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadQueues(): Observable<AdminQueue[]> {
|
||||
const queues: AdminQueue[] = [
|
||||
{ name: 'order-notifications', depth: 3, status: 'healthy' },
|
||||
{ name: 'media-processing', depth: 0, status: 'healthy' },
|
||||
{ name: 'webhook-delivery', depth: 12, status: 'degraded' },
|
||||
];
|
||||
return of(queues).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadWebhooks(): Observable<AdminWebhookDelivery[]> {
|
||||
return of(Array.from({ length: 8 }, (_, index) => ({
|
||||
id: `webhook-${index + 1}`,
|
||||
endpoint: index % 2 === 0 ? 'https://partner.example.com/orders' : 'https://partner.example.com/inventory',
|
||||
event: index % 2 === 0 ? 'order.created' : 'inventory.updated',
|
||||
status: index % 5 === 0 ? 'failed' : index % 4 === 0 ? 'pending' : 'delivered',
|
||||
timestamp: new Date(Date.now() - index * 3 * 60 * 60 * 1000).toISOString(),
|
||||
} as AdminWebhookDelivery))).pipe(delay(50));
|
||||
}
|
||||
|
||||
private ensureEvents(): AdminMonitoringEvent[] {
|
||||
if (!this.events) {
|
||||
this.events = Array.from({ length: EVENT_COUNT }, (_, index) => this.seedEvent(index));
|
||||
}
|
||||
return this.events;
|
||||
}
|
||||
|
||||
private seedEvent(index: number): AdminMonitoringEvent {
|
||||
const category = CATEGORIES[index % CATEGORIES.length];
|
||||
const level: AdminMonitoringLevel = category === 'error' || category === 'failed_login' ? 'error' : category === 'warning' ? 'warning' : 'info';
|
||||
return {
|
||||
id: `event-${index + 1}`,
|
||||
category,
|
||||
level,
|
||||
message: this.messageFor(category, index),
|
||||
actor: ACTORS[index % ACTORS.length],
|
||||
timestamp: new Date(Date.now() - index * 45 * 60 * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private messageFor(category: AdminMonitoringCategory, index: number): string {
|
||||
switch (category) {
|
||||
case 'audit': return `Admin action recorded (#${index})`;
|
||||
case 'security': return `Security policy check passed (#${index})`;
|
||||
case 'login': return `Successful admin login (#${index})`;
|
||||
case 'failed_login': return `Failed login attempt (#${index})`;
|
||||
case 'api': return `GET /api/products responded 200 in ${80 + index}ms`;
|
||||
case 'error': return `Unhandled exception in checkout flow (#${index})`;
|
||||
case 'warning': return `Slow query detected (#${index})`;
|
||||
default: return `Event #${index}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user