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:
Bastian Wagner
2026-08-04 16:21:39 +02:00
parent 24c509c0d5
commit f1b4f7e5b4
30 changed files with 1207 additions and 4 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View 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%;
}
}

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

View 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 },
);
},
});
}
}

View File

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

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