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:
sdarbinyan
2026-07-15 11:12:47 +04:00
parent 17adc9e9fd
commit a67ea17ad2
12 changed files with 298 additions and 2 deletions

View File

@@ -369,6 +369,28 @@ routes at all, this is a net-new admin section.
- Wired into `AdminDashboardFacade`'s Quick Actions list (`dashboard.actionUsers` - Wired into `AdminDashboardFacade`'s Quick Actions list (`dashboard.actionUsers`
-> `/:lang/backoffice/users`). -> `/:lang/backoffice/users`).
## Sprint 26 - Monitoring (mock/local, health reuses real data)
`features/admin/monitoring/`, single page at `/:lang/backoffice/monitoring`
(new Dashboard Quick Action).
- **Health**: reuses `AdminDashboardFacade.healthChecks` directly (the same
real, non-mocked bootstrap-validation checks from Sprint 19's dashboard)
instead of duplicating the logic - this is the one section on this page
backed by real data.
- **Audit / security / login / failed-login / API / error / warning
events**: one unified `AdminMonitoringEvent` feed (`category` + `level`
discriminators) with category filter + search, seeded with 40
deterministic synthetic entries by `AdminMonitoringLocalGateway` - no
logging backend exists anywhere in this system, so there is nothing real
to read from.
- **Queue monitoring**: 3 mock named queues with depth + status.
- **Webhook monitoring**: mock delivery log (endpoint/event/status/time).
- This is deliberately a separate, system-wide log from the two
narrower-scoped audit trails added earlier: Sprint 24's per-transaction
audit and Sprint 25's per-user audit. No consolidation attempted - they
track different things.
## Known gaps / backend needs ## Known gaps / backend needs
- **Dashboard metrics endpoint.** Categories/Products counts are computed - **Dashboard metrics endpoint.** Categories/Products counts are computed

View File

@@ -160,6 +160,14 @@ Plus, if authenticated history/wishlist/compare/saved-searches sync is wanted: `
**Frontend files:** implement `AdminUsersApiGateway` against `AdminUsersGateway` (`services/admin-users-gateway.interface.ts`) and rebind via an injection token. **Frontend files:** implement `AdminUsersApiGateway` against `AdminUsersGateway` (`services/admin-users-gateway.interface.ts`) and rebind via an injection token.
## 15. Monitoring (Sprint 26, mock/local except Health)
**Current behavior:** `features/admin/monitoring/` — Health section reads real data (`AdminDashboardFacade.healthChecks`, unchanged from Sprint 19). Everything else (audit/security/login/failed-login/API/error/warning event feed, queue depths, webhook deliveries) is synthetic, seeded once in `AdminMonitoringLocalGateway` — no logging, queue, or webhook infrastructure exists anywhere in this system.
**Needed:** real structured logging with a query API (by category/level/actor/time range), real queue introspection (whatever job runner ships), and real webhook delivery tracking once webhooks exist as a feature at all.
**Frontend files:** implement `AdminMonitoringApiGateway`-equivalent methods against a to-be-defined `AdminMonitoringGateway` interface (`AdminMonitoringLocalGateway` currently has no interface extracted — add one when a real implementation is built, mirroring the pattern used everywhere else in `admin/*`).
## Known reliability issues ## Known reliability issues
### Production 502/504 Bad Gateway on refresh / back-navigation ### Production 502/504 Bad Gateway on refresh / back-navigation

View File

@@ -59,8 +59,9 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin
- [x] `docs/ADMIN.md` (new Sprint 25 section), `docs/BACKEND.md` item 14 added - [x] `docs/ADMIN.md` (new Sprint 25 section), `docs/BACKEND.md` item 14 added
- Commit: `feat(admin): users and permissions` - Commit: `feat(admin): users and permissions`
## Sprint 26 — Monitoring ## Sprint 26 — Monitoring ✅ done
- [ ] Audit/security/login logs views (local), API/error/warning feed, queue/webhook placeholders, health page - [x] Audit/security/login/failed-login logs (unified event feed, category filter), API/error/warning feed, queue/webhook mock views, health page (reuses real Sprint 19 healthChecks)
- [x] `docs/ADMIN.md` (new Sprint 26 section), `docs/BACKEND.md` item 15 added
- Commit: `feat(admin): monitoring center` - Commit: `feat(admin): monitoring center`
## Sprint 27 — Analytics ## Sprint 27 — Analytics

View File

@@ -112,6 +112,10 @@ const coreRoutes: Routes = [
path: 'users', path: 'users',
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent) loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent)
}, },
{
path: 'monitoring',
loadComponent: () => import('./features/admin/monitoring/pages/admin-monitoring-page.component').then(m => m.AdminMonitoringPageComponent)
},
{ path: '**', redirectTo: 'dashboard' } { path: '**', redirectTo: 'dashboard' }
] ]
}, },

View File

@@ -19,6 +19,7 @@ const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
{ id: 'orders', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] }, { id: 'orders', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] }, { id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
{ id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] }, { id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
{ id: 'monitoring', labelKey: 'dashboard.actionMonitoring', route: ['backoffice', 'monitoring'] },
{ id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] }, { id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] },
]; ];

View File

@@ -18,6 +18,8 @@ export type AdminDashboardQuickActionId =
| 'transactions' | 'transactions'
| 'orders' | 'orders'
| 'media-library' | 'media-library'
| 'users'
| 'monitoring'
| 'preview-marketplace'; | 'preview-marketplace';
export interface AdminDashboardQuickAction { export interface AdminDashboardQuickAction {

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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; } }

View File

@@ -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();
}
}

View File

@@ -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}`;
}
}
}