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