feat: add admin log viewer, log retention cleanup, and manual job triggers
Global admins couldn't see the app's event log (no read endpoint or UI existed for it) and had no way to clean up old entries or re-run a scheduled job without touching the database or server directly. Backend: - LoggingService.findLogs() + admin-only LogsController (GET admin/logs) with level/event/date-range/search filtering and pagination, mirroring AdminUsersService.findPlayers(). - LogRetentionScheduler deletes log entries older than LOG_RETENTION_DAYS (default 365, via app.config.ts), following the existing @Cron scheduler pattern. - Admin-only POST admin/run endpoints on CashboxExportController and RecurringTransactionsController that invoke the existing schedulers' public run methods on demand - both are safe to re-run since their "due" queries advance nextRunDate only after a successful run. Frontend: - New /logs page (global-admin gated, same pattern as /users): AG-Grid infinite-scroll table with level/event/date-range/search filters, plus buttons to trigger the two jobs now and see the result land in the grid immediately. - LogsApi, and triggerRunNow() added to the existing CashboxExportApi and RecurringTransactionApi. - Discoverability link from /users to /logs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,11 @@ export const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/users/users').then((m) => m.Users),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/logs/logs').then((m) => m.Logs),
|
||||
},
|
||||
{
|
||||
path: 't/:token/:playerId',
|
||||
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { LogsApi } from './logs-api';
|
||||
|
||||
describe('LogsApi', () => {
|
||||
let api: LogsApi;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
api = TestBed.inject(LogsApi);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('loads logs with page and limit only when no filters are set', () => {
|
||||
api.loadLogs({ page: 2, limit: 50 }).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}admin/logs?page=2&limit=50`);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({ data: [], page: 2, limit: 50, total: 0, hasNextPage: false });
|
||||
});
|
||||
|
||||
it('includes level, event, date-range and search filters when set', () => {
|
||||
api
|
||||
.loadLogs({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
level: 'ERROR',
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-31',
|
||||
search: 'teamId=5',
|
||||
})
|
||||
.subscribe();
|
||||
const request = httpMock.expectOne(
|
||||
`${environment.apiUrl}admin/logs?level=ERROR&event=cashbox_export_subscription_run_fail&from=2026-01-01&to=2026-01-31&search=teamId=5&page=1&limit=50`,
|
||||
);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({ data: [], page: 1, limit: 50, total: 0, hasNextPage: false });
|
||||
});
|
||||
});
|
||||
26
myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts
Normal file
26
myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { LogPage, LogQuery } from '../../models/log.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LogsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}admin/logs`;
|
||||
|
||||
loadLogs(query: LogQuery): Observable<LogPage> {
|
||||
return this.http.get<LogPage>(this.baseUrl, { params: this.toParams(query) });
|
||||
}
|
||||
|
||||
private toParams(query: LogQuery): HttpParams {
|
||||
let params = new HttpParams();
|
||||
if (query.level) params = params.set('level', query.level);
|
||||
if (query.event) params = params.set('event', query.event);
|
||||
if (query.from) params = params.set('from', query.from);
|
||||
if (query.to) params = params.set('to', query.to);
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
params = params.set('page', query.page).set('limit', query.limit);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,11 @@ describe('CashboxExportApi', () => {
|
||||
expect(request.request.body).toEqual(update);
|
||||
request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('triggers the due-subscriptions run now', () => {
|
||||
api.triggerRunNow().subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/admin/run`);
|
||||
expect(request.request.method).toBe('POST');
|
||||
request.flush(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,4 +33,8 @@ export class CashboxExportApi {
|
||||
): Observable<CashboxExportSubscription> {
|
||||
return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto);
|
||||
}
|
||||
|
||||
triggerRunNow(): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/admin/run`, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,4 +62,11 @@ describe('RecurringTransactionApi', () => {
|
||||
expect(request.request.method).toBe('DELETE');
|
||||
request.flush(null);
|
||||
});
|
||||
|
||||
it('triggers the due-recurring-transactions run now', () => {
|
||||
api.triggerRunNow().subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/admin/run`);
|
||||
expect(request.request.method).toBe('POST');
|
||||
request.flush(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,4 +33,8 @@ export class RecurringTransactionApi {
|
||||
deleteRecurringTransaction(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
triggerRunNow(): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/admin/run`, null);
|
||||
}
|
||||
}
|
||||
|
||||
93
myteamwallet_frontend_modern/src/app/features/logs/logs.html
Normal file
93
myteamwallet_frontend_modern/src/app/features/logs/logs.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<main class="logs-page">
|
||||
<a mat-button routerLink="/" class="back-link"><mat-icon>arrow_back</mat-icon>Zurück</a>
|
||||
<header class="page-header">
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Logs</h1>
|
||||
<p>System- und Admin-Ereignisse im Überblick.</p>
|
||||
</header>
|
||||
|
||||
@if (!isAdmin()) {
|
||||
<div class="page-state">
|
||||
<mat-icon>lock</mat-icon>
|
||||
<strong>Kein Zugriff</strong>
|
||||
<span>Diese Seite ist nur für Administratoren sichtbar.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="admin-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="cashboxRunning()"
|
||||
(click)="triggerCashboxExportRun()"
|
||||
>
|
||||
Cashbox-Export jetzt ausführen
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="recurringRunning()"
|
||||
(click)="triggerRecurringTransactionsRun()"
|
||||
>
|
||||
Wiederkehrende Buchungen jetzt prüfen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Level</mat-label>
|
||||
<mat-select [value]="levelFilter()" (selectionChange)="levelFilter.set($event.value); onFilterChange()">
|
||||
@for (option of levelOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Event</mat-label>
|
||||
<mat-select [value]="eventFilter()" (selectionChange)="eventFilter.set($event.value); onFilterChange()">
|
||||
@for (option of eventOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Von</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[value]="fromFilter()"
|
||||
(change)="fromFilter.set($any($event.target).value); onFilterChange()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Bis</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[value]="toFilter()"
|
||||
(change)="toFilter.set($any($event.target).value); onFilterChange()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Suche in Details</mat-label>
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input matInput type="search" (input)="onSearchInput($any($event.target).value)" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<ag-grid-angular
|
||||
class="logs-grid"
|
||||
[theme]="gridTheme"
|
||||
[columnDefs]="columnDefs"
|
||||
[getRowId]="getRowId"
|
||||
rowModelType="infinite"
|
||||
[cacheBlockSize]="50"
|
||||
[pagination]="true"
|
||||
[paginationPageSize]="50"
|
||||
(gridReady)="onGridReady($event)"
|
||||
/>
|
||||
}
|
||||
</main>
|
||||
90
myteamwallet_frontend_modern/src/app/features/logs/logs.scss
Normal file
90
myteamwallet_frontend_modern/src/app/features/logs/logs.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
.logs-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-left: -12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin: 20px 0 26px;
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
|
||||
.page-header > p:last-child {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 6px;
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-state {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filters mat-form-field {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.logs-grid {
|
||||
height: 640px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.logs-page {
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
151
myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts
Normal file
151
myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { AuthStore } from '../../core/auth/auth-store';
|
||||
import { CashboxExportApi } from '../../core/team/cashbox-export-api';
|
||||
import { RecurringTransactionApi } from '../../core/team/recurring-transaction-api';
|
||||
import { LogsApi } from '../../core/logs/logs-api';
|
||||
import { Logs } from './logs';
|
||||
|
||||
describe('Logs', () => {
|
||||
const entries = [
|
||||
{
|
||||
id: 1,
|
||||
level: 'ERROR' as const,
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
details: 'subscriptionId=1 teamId=5: smtp down',
|
||||
userId: -1,
|
||||
createdAt: '2026-08-04T04:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
async function setup(isAdmin = true) {
|
||||
const loadLogs = vi.fn(() => of({ data: entries, page: 1, limit: 50, total: 1, hasNextPage: false }));
|
||||
const triggerCashboxRun = vi.fn(() => of(undefined));
|
||||
const triggerRecurringRun = vi.fn(() => of(undefined));
|
||||
const snackBarOpen = vi.fn();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Logs],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AuthStore, useValue: { isGlobalAdmin: signal(isAdmin) } },
|
||||
{ provide: LogsApi, useValue: { loadLogs } },
|
||||
{ provide: CashboxExportApi, useValue: { triggerRunNow: triggerCashboxRun } },
|
||||
{ provide: RecurringTransactionApi, useValue: { triggerRunNow: triggerRecurringRun } },
|
||||
{ provide: MatSnackBar, useValue: { open: snackBarOpen } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(Logs);
|
||||
fixture.detectChanges();
|
||||
return {
|
||||
fixture,
|
||||
component: fixture.componentInstance,
|
||||
loadLogs,
|
||||
triggerCashboxRun,
|
||||
triggerRecurringRun,
|
||||
snackBarOpen,
|
||||
};
|
||||
}
|
||||
|
||||
// AG Grid's real component initialization (layout/ResizeObserver setup) can
|
||||
// run slower under the full suite's parallel load than in isolation, so this
|
||||
// gets a longer timeout rather than the vitest default 5s.
|
||||
it('shows the log grid and trigger buttons to a global admin', async () => {
|
||||
const { fixture } = await setup(true);
|
||||
|
||||
expect(fixture.nativeElement.querySelector('ag-grid-angular')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Cashbox-Export jetzt ausführen');
|
||||
expect(fixture.nativeElement.textContent).toContain('Wiederkehrende Buchungen jetzt prüfen');
|
||||
}, 15000);
|
||||
|
||||
it('hides the grid and shows no access for a non-admin', async () => {
|
||||
const { fixture } = await setup(false);
|
||||
|
||||
expect(fixture.nativeElement.querySelector('ag-grid-angular')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Kein Zugriff');
|
||||
});
|
||||
|
||||
it('builds a logs datasource sorted newest first with page/limit only when no filters are set', async () => {
|
||||
const { component, loadLogs } = await setup();
|
||||
const successCallback = vi.fn();
|
||||
|
||||
const datasource = component['buildLogsDatasource']();
|
||||
datasource.getRows({
|
||||
startRow: 0,
|
||||
endRow: 50,
|
||||
sortModel: [],
|
||||
filterModel: {},
|
||||
successCallback,
|
||||
failCallback: vi.fn(),
|
||||
} as unknown as Parameters<typeof datasource.getRows>[0]);
|
||||
|
||||
expect(loadLogs).toHaveBeenCalledWith({ page: 1, limit: 50 });
|
||||
expect(successCallback).toHaveBeenCalledWith(entries, 1);
|
||||
});
|
||||
|
||||
it('applies level, event, date-range and search filters to the datasource query', async () => {
|
||||
const { component, loadLogs } = await setup();
|
||||
component['levelFilter'].set('ERROR');
|
||||
component['eventFilter'].set('cashbox_export_subscription_run_fail');
|
||||
component['fromFilter'].set('2026-01-01');
|
||||
component['toFilter'].set('2026-01-31');
|
||||
component['search'].set('teamId=5');
|
||||
|
||||
const datasource = component['buildLogsDatasource']();
|
||||
datasource.getRows({
|
||||
startRow: 50,
|
||||
endRow: 100,
|
||||
sortModel: [],
|
||||
filterModel: {},
|
||||
successCallback: vi.fn(),
|
||||
failCallback: vi.fn(),
|
||||
} as unknown as Parameters<typeof datasource.getRows>[0]);
|
||||
|
||||
expect(loadLogs).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
limit: 50,
|
||||
level: 'ERROR',
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-31',
|
||||
search: 'teamId=5',
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers the cashbox export run and reloads the grid on success', async () => {
|
||||
const { component, triggerCashboxRun, snackBarOpen } = await setup();
|
||||
const reloadSpy = vi.spyOn(component as any, 'reloadLogs');
|
||||
|
||||
component['triggerCashboxExportRun']();
|
||||
|
||||
expect(triggerCashboxRun).toHaveBeenCalledTimes(1);
|
||||
expect(snackBarOpen).toHaveBeenCalled();
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error message when the cashbox export trigger fails', async () => {
|
||||
const { component, snackBarOpen } = await setup();
|
||||
(component as any).cashboxExportApi.triggerRunNow = vi.fn(() =>
|
||||
throwError(() => new Error('boom')),
|
||||
);
|
||||
|
||||
component['triggerCashboxExportRun']();
|
||||
|
||||
expect(snackBarOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers the recurring-transactions run and reloads the grid on success', async () => {
|
||||
const { component, triggerRecurringRun, snackBarOpen } = await setup();
|
||||
const reloadSpy = vi.spyOn(component as any, 'reloadLogs');
|
||||
|
||||
component['triggerRecurringTransactionsRun']();
|
||||
|
||||
expect(triggerRecurringRun).toHaveBeenCalledTimes(1);
|
||||
expect(snackBarOpen).toHaveBeenCalled();
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
241
myteamwallet_frontend_modern/src/app/features/logs/logs.ts
Normal file
241
myteamwallet_frontend_modern/src/app/features/logs/logs.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { AgGridAngular } from 'ag-grid-angular';
|
||||
import type {
|
||||
ColDef,
|
||||
GetRowIdParams,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
IGetRowsParams,
|
||||
} from 'ag-grid-community';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { AuthStore } from '../../core/auth/auth-store';
|
||||
import { CashboxExportApi } from '../../core/team/cashbox-export-api';
|
||||
import { RecurringTransactionApi } from '../../core/team/recurring-transaction-api';
|
||||
import { LogsApi } from '../../core/logs/logs-api';
|
||||
import { LogEntry, LogLevel, LogQuery } from '../../models/log.model';
|
||||
import '../../shared/ag-grid/ag-grid-modules';
|
||||
import { teamwalletGridTheme } from '../../shared/ag-grid/ag-grid-theme';
|
||||
|
||||
const LOG_LEVEL_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: '', label: 'Alle Level' },
|
||||
{ value: 'FATAL', label: 'FATAL' },
|
||||
{ value: 'ERROR', label: 'ERROR' },
|
||||
{ value: 'WARN', label: 'WARN' },
|
||||
{ value: 'INFO', label: 'INFO' },
|
||||
{ value: 'DEBUG', label: 'DEBUG' },
|
||||
{ value: 'TRACE', label: 'TRACE' },
|
||||
];
|
||||
|
||||
// Kept in sync manually with LOGEVENT_VALUES (myteamwallet_backend/src/database/logging/model/logging-event.type.ts),
|
||||
// the same way transaction type labels are already duplicated on the frontend elsewhere in this app.
|
||||
const LOG_EVENT_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: '', label: 'Alle Events' },
|
||||
{ value: 'user_create', label: 'user_create' },
|
||||
{ value: 'application_start', label: 'application_start' },
|
||||
{ value: 'transaction_create', label: 'transaction_create' },
|
||||
{ value: 'team_transaction_create', label: 'team_transaction_create' },
|
||||
{ value: 'team_transaction_get', label: 'team_transaction_get' },
|
||||
{ value: 'user_login_success', label: 'user_login_success' },
|
||||
{ value: 'user_login_fail', label: 'user_login_fail' },
|
||||
{ value: 'user_token_verification_success', label: 'user_token_verification_success' },
|
||||
{ value: 'user_token_verification_fail', label: 'user_token_verification_fail' },
|
||||
{ value: 'user_invite_link_create', label: 'user_invite_link_create' },
|
||||
{ value: 'user_invite_link_validate', label: 'user_invite_link_validate' },
|
||||
{ value: 'user_invite_link_validate_fail', label: 'user_invite_link_validate_fail' },
|
||||
{ value: 'transaction_create_fail', label: 'transaction_create_fail' },
|
||||
{ value: 'transaction_reverse', label: 'transaction_reverse' },
|
||||
{ value: 'player_creation', label: 'player_creation' },
|
||||
{ value: 'admin_user_profile_update', label: 'admin_user_profile_update' },
|
||||
{ value: 'admin_user_role_update', label: 'admin_user_role_update' },
|
||||
{ value: 'admin_user_status_update', label: 'admin_user_status_update' },
|
||||
{ value: 'admin_player_assign', label: 'admin_player_assign' },
|
||||
{ value: 'admin_player_unlink', label: 'admin_player_unlink' },
|
||||
{ value: 'player_active_update', label: 'player_active_update' },
|
||||
{ value: 'player_team_role_update', label: 'player_team_role_update' },
|
||||
{ value: 'penalty_catalog_create', label: 'penalty_catalog_create' },
|
||||
{ value: 'penalty_catalog_update', label: 'penalty_catalog_update' },
|
||||
{ value: 'penalty_catalog_delete', label: 'penalty_catalog_delete' },
|
||||
{ value: 'team_create', label: 'team_create' },
|
||||
{ value: 'team_permissions_update', label: 'team_permissions_update' },
|
||||
{ value: 'scheduled_recurring_transaction_check_start', label: 'scheduled_recurring_transaction_check_start' },
|
||||
{
|
||||
value: 'scheduled_recurring_transaction_check_finished',
|
||||
label: 'scheduled_recurring_transaction_check_finished',
|
||||
},
|
||||
{ value: 'recurring_transaction_create', label: 'recurring_transaction_create' },
|
||||
{ value: 'recurring_transaction_update', label: 'recurring_transaction_update' },
|
||||
{ value: 'recurring_transaction_delete', label: 'recurring_transaction_delete' },
|
||||
{ value: 'recurring_transaction_run', label: 'recurring_transaction_run' },
|
||||
{ value: 'cashbox_export_download', label: 'cashbox_export_download' },
|
||||
{ value: 'cashbox_export_subscription_update', label: 'cashbox_export_subscription_update' },
|
||||
{ value: 'cashbox_export_subscription_run', label: 'cashbox_export_subscription_run' },
|
||||
{ value: 'cashbox_export_subscription_run_fail', label: 'cashbox_export_subscription_run_fail' },
|
||||
{ value: 'log_retention_cleanup_run', label: 'log_retention_cleanup_run' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs',
|
||||
imports: [
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
AgGridAngular,
|
||||
],
|
||||
templateUrl: './logs.html',
|
||||
styleUrl: './logs.scss',
|
||||
})
|
||||
export class Logs {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly logsApi = inject(LogsApi);
|
||||
private readonly cashboxExportApi = inject(CashboxExportApi);
|
||||
private readonly recurringTransactionApi = inject(RecurringTransactionApi);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
protected readonly isAdmin = this.authStore.isGlobalAdmin;
|
||||
protected readonly gridTheme = teamwalletGridTheme;
|
||||
protected readonly levelOptions = LOG_LEVEL_OPTIONS;
|
||||
protected readonly eventOptions = LOG_EVENT_OPTIONS;
|
||||
|
||||
protected readonly levelFilter = signal('');
|
||||
protected readonly eventFilter = signal('');
|
||||
protected readonly fromFilter = signal('');
|
||||
protected readonly toFilter = signal('');
|
||||
protected readonly search = signal('');
|
||||
protected readonly cashboxRunning = signal(false);
|
||||
protected readonly recurringRunning = signal(false);
|
||||
|
||||
private readonly searchInput$ = new Subject<string>();
|
||||
private gridApi?: GridApi<LogEntry>;
|
||||
|
||||
protected readonly columnDefs: ColDef<LogEntry>[] = [
|
||||
{
|
||||
headerName: 'Zeitpunkt',
|
||||
field: 'createdAt',
|
||||
width: 170,
|
||||
valueFormatter: (params) =>
|
||||
params.value
|
||||
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'medium' }).format(
|
||||
new Date(params.value),
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
headerName: 'Level',
|
||||
field: 'level',
|
||||
width: 100,
|
||||
cellClass: (params) => `log-level log-level--${(params.value ?? '').toLowerCase()}`,
|
||||
},
|
||||
{ headerName: 'Event', field: 'event', minWidth: 220, flex: 1 },
|
||||
{
|
||||
headerName: 'Wer',
|
||||
field: 'userId',
|
||||
width: 90,
|
||||
valueFormatter: (params) => (params.value === -1 ? 'System' : `#${params.value}`),
|
||||
},
|
||||
{ headerName: 'Details', field: 'details', minWidth: 260, flex: 2 },
|
||||
{
|
||||
headerName: 'Dauer',
|
||||
field: 'duration',
|
||||
width: 90,
|
||||
valueFormatter: (params) => (params.value != null ? `${params.value} ms` : ''),
|
||||
},
|
||||
];
|
||||
|
||||
protected readonly getRowId = (params: GetRowIdParams<LogEntry>) => String(params.data.id);
|
||||
|
||||
constructor() {
|
||||
this.searchInput$.pipe(debounceTime(300), takeUntilDestroyed()).subscribe((value) => {
|
||||
this.search.set(value);
|
||||
this.reloadLogs();
|
||||
});
|
||||
}
|
||||
|
||||
protected onGridReady(event: GridReadyEvent<LogEntry>): void {
|
||||
this.gridApi = event.api;
|
||||
this.reloadLogs();
|
||||
}
|
||||
|
||||
protected onFilterChange(): void {
|
||||
this.reloadLogs();
|
||||
}
|
||||
|
||||
protected onSearchInput(value: string): void {
|
||||
this.searchInput$.next(value);
|
||||
}
|
||||
|
||||
private reloadLogs(): void {
|
||||
this.gridApi?.setGridOption('datasource', this.buildLogsDatasource());
|
||||
}
|
||||
|
||||
private buildLogsDatasource(): IDatasource {
|
||||
return {
|
||||
getRows: (params: IGetRowsParams) => {
|
||||
const limit = Math.max(1, params.endRow - params.startRow);
|
||||
const page = Math.floor(params.startRow / limit) + 1;
|
||||
const query: LogQuery = {
|
||||
page,
|
||||
limit,
|
||||
...(this.levelFilter() ? { level: this.levelFilter() as LogLevel } : {}),
|
||||
...(this.eventFilter() ? { event: this.eventFilter() } : {}),
|
||||
...(this.fromFilter() ? { from: this.fromFilter() } : {}),
|
||||
...(this.toFilter() ? { to: this.toFilter() } : {}),
|
||||
...(this.search().trim() ? { search: this.search().trim() } : {}),
|
||||
};
|
||||
|
||||
this.logsApi.loadLogs(query).subscribe({
|
||||
next: (result) => params.successCallback(result.data, result.total),
|
||||
error: () => params.failCallback(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected triggerCashboxExportRun(): void {
|
||||
if (this.cashboxRunning()) return;
|
||||
this.cashboxRunning.set(true);
|
||||
this.cashboxExportApi.triggerRunNow().subscribe({
|
||||
next: () => {
|
||||
this.cashboxRunning.set(false);
|
||||
this.snackBar.open('Cashbox-Export wurde ausgeführt.', undefined, { duration: 4000 });
|
||||
this.reloadLogs();
|
||||
},
|
||||
error: () => {
|
||||
this.cashboxRunning.set(false);
|
||||
this.snackBar.open('Cashbox-Export konnte nicht ausgeführt werden.', undefined, { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected triggerRecurringTransactionsRun(): void {
|
||||
if (this.recurringRunning()) return;
|
||||
this.recurringRunning.set(true);
|
||||
this.recurringTransactionApi.triggerRunNow().subscribe({
|
||||
next: () => {
|
||||
this.recurringRunning.set(false);
|
||||
this.snackBar.open('Wiederkehrende Buchungen wurden geprüft.', undefined, { duration: 4000 });
|
||||
this.reloadLogs();
|
||||
},
|
||||
error: () => {
|
||||
this.recurringRunning.set(false);
|
||||
this.snackBar.open(
|
||||
'Wiederkehrende Buchungen konnten nicht geprüft werden.',
|
||||
undefined,
|
||||
{ duration: 5000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
<p class="eyebrow">Organisation</p>
|
||||
<h1>Benutzer</h1>
|
||||
<p>Konten und sichtbare Teamzuordnungen im Überblick.</p>
|
||||
@if (isAdmin()) {
|
||||
<a mat-button routerLink="/logs"><mat-icon>receipt_long</mat-icon>Logs</a>
|
||||
}
|
||||
</header>
|
||||
|
||||
<form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search">
|
||||
|
||||
29
myteamwallet_frontend_modern/src/app/models/log.model.ts
Normal file
29
myteamwallet_frontend_modern/src/app/models/log.model.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type LogLevel = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
level: LogLevel;
|
||||
event: string;
|
||||
details: string;
|
||||
userId: number;
|
||||
duration?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LogQuery {
|
||||
page: number;
|
||||
limit: number;
|
||||
level?: LogLevel;
|
||||
event?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface LogPage {
|
||||
data: LogEntry[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user