Files
portal-webapp/portal_client/src/app/features/feature-page.ts
T
iparenteau a6df549919
Locusworks Team/portal-webapp/pipeline/head There was a failure building this commit
Upgraded all libraries, switched to npm, upgraded angular and jdk21
2026-09-04 15:22:35 -05:00

104 lines
8.5 KiB
TypeScript

import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatTableModule } from '@angular/material/table';
import { FEATURES } from '../core/models';
import { PortalApiService } from '../core/portal-api.service';
import { RealtimeService } from '../core/realtime.service';
import { SessionService } from '../core/session.service';
@Component({
selector: 'app-feature-page',
imports: [MatButtonModule, MatCardModule, MatDialogModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTableModule],
template: `
<header>
<div><h1>{{ feature().title }}</h1><p>Manage {{ feature().title.toLowerCase() }}</p></div>
<div class="toolbar">
<mat-form-field subscriptSizing="dynamic"><mat-label>Filter</mat-label><input matInput (input)="filter.set($any($event.target).value)"><mat-icon matSuffix>search</mat-icon></mat-form-field>
@if (feature().path === 'syslogs' && session.hasPermission('PURGE_LOGS')) { <button mat-flat-button (click)="purgeLogs()"><mat-icon>delete_sweep</mat-icon>Purge</button> }
@if (canSave()) { <button mat-flat-button (click)="edit()"><mat-icon>add</mat-icon>Add</button> }
<button mat-button (click)="load()"><mat-icon>refresh</mat-icon>Refresh</button>
</div>
</header>
@if (loading()) { <mat-spinner diameter="42" /> }
@else if (error()) { <mat-card class="error">{{ error() }}</mat-card> }
@else if (rows().length === 0) { <mat-card class="empty">No records found.</mat-card> }
@else {
<mat-card><div class="table-container"><table mat-table [dataSource]="displayedRows()">
@for (column of dataColumns(); track column) {
<ng-container [matColumnDef]="column"><th mat-header-cell *matHeaderCellDef>{{ label(column) }}</th><td mat-cell *matCellDef="let row">{{ display(row[column]) }}</td></ng-container>
}
@if (hasActions()) {
<ng-container matColumnDef="actions"><th mat-header-cell *matHeaderCellDef>Actions</th><td mat-cell *matCellDef="let row">
@if (canSave()) { <button mat-icon-button aria-label="Edit" (click)="edit(row)"><mat-icon>edit</mat-icon></button> }
@if (feature().path === 'scripts' && (session.hasPermission('VIEW_SCRIPT_SCHED_STATUS') || session.hasPermission('DELETE_SCRIPT_SCHED_STATUS'))) { <button mat-icon-button aria-label="Schedules and run status" (click)="openScriptStatus(row)"><mat-icon>schedule</mat-icon></button> }
@if (canExecute()) { <button mat-icon-button [attr.aria-label]="feature().path === 'scripts' ? 'Run' : 'Pull'" (click)="execute(row)"><mat-icon>{{ feature().path === 'scripts' ? 'play_arrow' : 'sync' }}</mat-icon></button> }
@if (canDelete()) { <button mat-icon-button aria-label="Delete" (click)="remove(row)"><mat-icon>delete</mat-icon></button> }
</td></ng-container>
}
<tr mat-header-row *matHeaderRowDef="columns()"></tr><tr mat-row *matRowDef="let row; columns: columns()"></tr>
</table></div></mat-card>
}`,
styles: [`header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem}h1{margin:0}p{color:#607d8b}.toolbar{display:flex;gap:.5rem}.table-container{overflow:auto}table{width:100%}.error{color:#b00020;padding:1rem}.empty{padding:2rem;text-align:center}`]
})
export class FeaturePageComponent {
private readonly route = inject(ActivatedRoute);
private readonly api = inject(PortalApiService);
private readonly realtime = inject(RealtimeService);
readonly session = inject(SessionService);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
readonly loading = signal(true);
readonly error = signal('');
readonly rows = signal<Record<string, unknown>[]>([]);
readonly filter = signal('');
readonly displayedRows = computed(() => { const term=this.filter().trim().toLowerCase(); return term ? this.rows().filter(row=>Object.values(row).some(value=>String(value??'').toLowerCase().includes(term))) : this.rows(); });
readonly feature = computed(() => FEATURES.find(feature => feature.path === this.route.snapshot.data['feature'])!);
readonly dataColumns = computed(() => Object.keys(this.rows()[0] ?? {}).filter(key => typeof this.rows()[0]?.[key] !== 'object').slice(0, 8));
readonly columns = computed(() => this.hasActions() ? [...this.dataColumns(), 'actions'] : this.dataColumns());
constructor() { this.load(); this.realtime.triggers.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.load()); }
hasActions(): boolean { return this.canSave() || this.canDelete() || this.canExecute() || (this.feature().path === 'scripts' && (this.session.hasPermission('VIEW_SCRIPT_SCHED_STATUS') || this.session.hasPermission('DELETE_SCRIPT_SCHED_STATUS'))); }
canSave(): boolean { return !!this.feature().editor && !!this.feature().saveEndpoint && this.session.hasPermission(this.feature().savePermission!); }
canDelete(): boolean { return !!this.feature().deleteEndpoint && this.session.hasPermission(this.feature().deletePermission!); }
canExecute(): boolean { return !!this.feature().executeEndpoint && this.session.hasPermission(this.feature().executePermission!); }
load(): void {
this.loading.set(true); this.error.set('');
this.api.request<unknown>(this.feature().loadEndpoint).subscribe({
next: data => { this.rows.set(Array.isArray(data) ? data : (data ? [data as Record<string, unknown>] : [])); this.loading.set(false); },
error: error => { this.error.set(error.message); this.loading.set(false); }
});
}
async edit(row?: Record<string, unknown>): Promise<void> {
const config = { data: { feature: this.feature(), row }, disableClose: true, width: 'min(52rem, 95vw)' };
if (this.feature().editor === 'environment') {
const { EnvironmentDialogComponent } = await import('./environment-dialog');
this.dialog.open(EnvironmentDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
} else if (this.feature().editor === 'permission') {
const { PermissionDialogComponent } = await import('./permission-dialog');
this.dialog.open(PermissionDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
} else if (this.feature().editor === 'script') {
const { ScriptDialogComponent } = await import('./script-dialog');
this.dialog.open(ScriptDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
} else {
const { EntityDialogComponent } = await import('./entity-dialog');
this.dialog.open(EntityDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
}
}
remove(row: Record<string, unknown>): void { if (confirm(`Delete this ${this.feature().title} record?`)) this.api.request(this.feature().deleteEndpoint!, this.feature().editor === 'environment' ? [row] : row).subscribe({ next: () => this.load(), error: error => this.error.set(error.message) }); }
execute(row: Record<string, unknown>): void { const verb=this.feature().path==='scripts'?'run':'pull';if(!confirm(`Are you sure you want to ${verb} ${String(row['name']??'this item')}?`))return;this.api.request(this.feature().executeEndpoint!, row).subscribe({ next: () => this.load(), error: error => this.error.set(error.message) }); }
async purgeLogs(): Promise<void> { const {PurgeLogsDialogComponent}=await import('./purge-logs-dialog');this.dialog.open(PurgeLogsDialogComponent,{disableClose:true}).afterClosed().subscribe(count=>{if(count!==undefined)this.load();}); }
async openScriptStatus(row:Record<string,unknown>):Promise<void>{const {ScriptStatusDialogComponent}=await import('./script-status-dialog');this.dialog.open(ScriptStatusDialogComponent,{data:{script:row},width:'min(70rem,96vw)'}).afterClosed().subscribe(()=>this.load());}
label(value: string): string { return value.replace(/([A-Z])/g, ' $1').replace(/^./, character => character.toUpperCase()); }
display(value: unknown): string { return value == null ? '' : String(value); }
}