You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

368 lines
13 KiB

import { Component, OnInit, signal } from "@angular/core";
import { DatePipe } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { Router } from "@angular/router";
import { MatSnackBar } from "@angular/material/snack-bar";
import { ApiService } from "../core/api.service";
import { MATERIAL } from "../shared/material";
@Component({
standalone: true,
imports: [FormsModule, DatePipe, ...MATERIAL],
template: `<section class="page">
<div class="page-head">
<div>
<h1>Administration</h1>
<p class="muted">
Benutzer, Daten, Sicherungen und Ausbildungsstellen-Watcher
</p>
</div>
</div>
<div class="admin-nav">
@for (item of sections; track item.id) {
<button
mat-stroked-button
[class.selected]="section() === item.id"
(click)="section.set(item.id)"
>
<mat-icon>{{ item.icon }}</mat-icon
>{{ item.label }}
</button>
}
</div>
@if (section() === "database") {
<div class="grid cards">
<mat-card
><mat-card-header
><mat-card-title>Datenbank sichern</mat-card-title></mat-card-header
><mat-card-content
><p>Aktuellen PostgreSQL-Dump als ZIP herunterladen.</p>
<button mat-flat-button (click)="backup()">
<mat-icon>download</mat-icon> Backup herunterladen
</button></mat-card-content
></mat-card
><mat-card><mat-card-header><mat-card-title>PgHero</mat-card-title></mat-card-header><mat-card-content><p>Datenbankabfragen, Speicher und Performance analysieren.</p><a mat-stroked-button href="/admin/pghero" target="_blank"><mat-icon>monitoring</mat-icon> PgHero öffnen</a></mat-card-content></mat-card
><mat-card class="danger"
><mat-card-header
><mat-card-title
>Datenbank wiederherstellen</mat-card-title
></mat-card-header
><mat-card-content
><p>
Eine Sicherung überschreibt den aktuellen Datenbestand. Vorher
unbedingt ein Backup herunterladen.
</p>
<input
#fileInput
type="file"
accept=".zip,.sql"
(change)="selectFile($event)"
/><button
mat-flat-button
color="warn"
[disabled]="!restoreFile() || busy()"
(click)="restore()"
>
<mat-icon>restore</mat-icon> Sicherung einspielen
</button></mat-card-content
></mat-card
>
</div>
}
@if (section() === "users") {
<mat-card class="source-form"><mat-card-content><mat-form-field appearance="outline"><mat-label>E-Mail</mat-label><input matInput type="email" [(ngModel)]="newUser.email"></mat-form-field><mat-form-field appearance="outline"><mat-label>Startpasswort</mat-label><input matInput type="password" [(ngModel)]="newUser.password"></mat-form-field><mat-checkbox [(ngModel)]="newUser.confirmed">Sofort bestätigen</mat-checkbox><button mat-flat-button (click)="createUser()">Benutzer anlegen</button></mat-card-content></mat-card>
<mat-card
><div class="table-wrap">
<table mat-table [dataSource]="users()">
<ng-container matColumnDef="email"
><th mat-header-cell *matHeaderCellDef>E-Mail</th>
<td mat-cell *matCellDef="let u">
{{ u.email }}
@if (u.is_admin) {
<strong>Admin</strong>
}
</td></ng-container
><ng-container matColumnDef="confirmed"
><th mat-header-cell *matHeaderCellDef>Bestätigt</th>
<td mat-cell *matCellDef="let u">
<mat-checkbox
[checked]="!!u.confirmed_at"
(change)="setConfirmed(u, $event.checked)"
></mat-checkbox></td></ng-container
><ng-container matColumnDef="entries"
><th mat-header-cell *matHeaderCellDef>Einträge</th>
<td mat-cell *matCellDef="let u">
{{ u.entries_count }}
</td></ng-container
><ng-container matColumnDef="actions"
><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let u">
<button
mat-icon-button
(click)="removeUser(u)"
[disabled]="u.is_admin"
>
<mat-icon>delete</mat-icon>
</button>
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="userColumns"></tr>
<tr mat-row *matRowDef="let row; columns: userColumns"></tr>
</table></div
></mat-card>
}
@if (section() === "entries") {
<mat-card
><div class="table-wrap">
<table mat-table [dataSource]="entries()">
<ng-container matColumnDef="date"
><th mat-header-cell *matHeaderCellDef>Datum</th>
<td mat-cell *matCellDef="let e">
{{ e.date | date: "dd.MM.yyyy" }}
</td></ng-container
><ng-container matColumnDef="user"
><th mat-header-cell *matHeaderCellDef>Benutzer</th>
<td mat-cell *matCellDef="let e">
{{ e.user_email }}
</td></ng-container
><ng-container matColumnDef="art"
><th mat-header-cell *matHeaderCellDef>Art</th>
<td mat-cell *matCellDef="let e">
{{ e.praktikums_typ }} / {{ e.entry_art }}
</td></ng-container
><ng-container matColumnDef="time"
><th mat-header-cell *matHeaderCellDef>Zeit</th>
<td mat-cell *matCellDef="let e">
{{ e.hours }} h {{ e.minutes }} min
</td></ng-container
><ng-container matColumnDef="actions"
><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let e">
<button mat-icon-button (click)="removeEntry(e)">
<mat-icon>delete</mat-icon>
</button>
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="entryColumns"></tr>
<tr mat-row *matRowDef="let row; columns: entryColumns"></tr>
</table></div
></mat-card>
}
@if (section() === "watcher") {
<div class="page-head">
<h2>Ausbildungsstellen-Watcher</h2>
<button mat-flat-button (click)="runWatcher()">
<mat-icon>refresh</mat-icon> Jetzt prüfen
</button>
</div>
<mat-card class="source-form"
><mat-card-content
><mat-form-field appearance="outline"
><mat-label>Name</mat-label
><input matInput [(ngModel)]="sourceDraft.name" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>URL</mat-label
><input matInput [(ngModel)]="sourceDraft.url" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Typ</mat-label
><mat-select [(ngModel)]="sourceDraft.kind"
><mat-option value="html">HTML</mat-option
><mat-option value="rss">RSS</mat-option></mat-select
></mat-form-field
><mat-checkbox [(ngModel)]="sourceDraft.enabled">Aktiv</mat-checkbox
><button mat-flat-button (click)="saveSource()">
Quelle speichern
</button></mat-card-content
></mat-card
>
<div class="grid cards">
@for (s of sources(); track s.id) {
<mat-card
><mat-card-content
><strong>{{ s.name }}</strong
><a [href]="s.url" target="_blank">{{ s.url }}</a
><small
>Zuletzt geprüft:
{{
s.last_checked_at
? (s.last_checked_at | date: "dd.MM.yyyy HH:mm")
: "–"
}}</small
>
<div class="actions">
<button mat-button (click)="editSource(s)">Bearbeiten</button
><button mat-icon-button (click)="removeSource(s)">
<mat-icon>delete</mat-icon>
</button>
</div></mat-card-content
></mat-card
>
}
</div>
<h2>Gefundene Treffer</h2>
@for (h of hits(); track h.id) {
<mat-card class="hit"
><mat-card-content
><strong>{{ h.title }}</strong
><span>{{ h.source_name }}</span>
<p>{{ h.snippet }}</p>
<div class="actions">
<a mat-button [href]="h.hit_url" target="_blank">Öffnen</a
><button mat-icon-button (click)="removeHit(h)">
<mat-icon>delete</mat-icon>
</button>
</div></mat-card-content
></mat-card
>
}
}
</section>`,
styles: [
`
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 22px;
}
.selected {
background: #dce9ff !important;
}
.danger {
border: 1px solid #e1a4a4;
}
.danger input {
margin: 10px 0 20px;
}
.source-form {
margin-bottom: 22px;
}
.source-form mat-card-content {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.source-form mat-form-field {
min-width: 220px;
}
.hit {
margin-bottom: 10px;
}
.hit mat-card-content,
.cards mat-card-content {
display: flex;
flex-direction: column;
gap: 8px;
}
table {
width: 100%;
}
`,
],
})
export class AdminDatabaseComponent implements OnInit {
readonly sections = [
{ id: "database", label: "Backup & Restore", icon: "database" },
{ id: "users", label: "Benutzer", icon: "group" },
{ id: "entries", label: "Alle Einträge", icon: "list_alt" },
{ id: "watcher", label: "Ausbildungs-Watcher", icon: "travel_explore" },
];
section = signal("database");
restoreFile = signal<File | null>(null);
busy = signal(false);
users = signal<any[]>([]);
entries = signal<any[]>([]);
sources = signal<any[]>([]);
hits = signal<any[]>([]);
userColumns = ["email", "confirmed", "entries", "actions"];
entryColumns = ["date", "user", "art", "time", "actions"];
sourceDraft: any = { name: "", url: "", kind: "html", enabled: true };
newUser: any = { email: "", password: "", confirmed: true };
constructor(
private api: ApiService,
private snack: MatSnackBar,
router: Router,
) {
if (api.user() && !api.user()!.is_admin) router.navigate(["/dashboard"]);
}
ngOnInit() {
this.reload();
}
reload() {
this.api.adminUsers().subscribe((v) => this.users.set(v));
this.api.adminEntries().subscribe((v) => this.entries.set(v));
this.api.trainingSources().subscribe((v) => this.sources.set(v));
this.api.trainingHits().subscribe((v) => this.hits.set(v));
}
selectFile(event: Event) {
this.restoreFile.set((event.target as HTMLInputElement).files?.[0] || null);
}
backup() {
this.api.databaseBackup().subscribe((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `praktikum-backup-${new Date().toISOString().slice(0, 10)}.zip`;
a.click();
URL.revokeObjectURL(a.href);
});
}
restore() {
const file = this.restoreFile();
if (
!file ||
!confirm(
"Aktuelle Datenbank wirklich mit dieser Sicherung überschreiben?",
)
)
return;
this.busy.set(true);
this.api.databaseRestore(file).subscribe({
next: (r) => {
this.busy.set(false);
this.snack.open(r.message, "OK");
this.reload();
},
error: (e) => {
this.busy.set(false);
this.snack.open(e.error?.error || "Restore fehlgeschlagen", "OK");
},
});
}
setConfirmed(u: any, value: boolean) {
this.api
.updateAdminUser(u.id, { confirmed: value })
.subscribe(() => this.reload());
}
createUser(){this.api.createAdminUser(this.newUser).subscribe({next:()=>{this.newUser={email:"",password:"",confirmed:true};this.reload();},error:e=>this.snack.open(e.error?.errors?.join(", ")||"Benutzer konnte nicht angelegt werden","OK")});}
removeUser(u: any) {
if (confirm(`${u.email} wirklich löschen?`))
this.api.deleteAdminUser(u.id).subscribe(() => this.reload());
}
removeEntry(e: any) {
if (confirm("Eintrag wirklich löschen?"))
this.api.deleteAdminEntry(e.id).subscribe(() => this.reload());
}
editSource(s: any) {
this.sourceDraft = { ...s };
}
saveSource() {
this.api.saveTrainingSource(this.sourceDraft).subscribe(() => {
this.sourceDraft = { name: "", url: "", kind: "html", enabled: true };
this.reload();
});
}
removeSource(s: any) {
if (confirm("Quelle und zugehörige Treffer löschen?"))
this.api.deleteTrainingSource(s.id).subscribe(() => this.reload());
}
removeHit(h: any) {
this.api.deleteTrainingHit(h.id).subscribe(() => this.reload());
}
runWatcher() {
this.api.runTrainingWatch().subscribe((r) => {
this.snack.open(`${r.new_hits} neue Treffer`, "OK");
this.reload();
});
}
}