All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
New /admin/system panel (operations.md §Maintenance jobs): the maintenance job list shows every registered job with truthful last-run data (new Job.lastDurationMs recorded by the scheduler) and a manual trigger that respects the run-mutex and is itself audit-logged; a backup card mirrors the sidecar's status.json including the freshness verdict; an audit-log viewer filters by actor, action, and time range with pagination; and a storage overview lists the largest ponds. Auth events and admin actions (grants, members, user/quota admin, plugins, settings, setup) now land in a new audit_log table through a central AuditService — which keeps emitting the established stdout log line — while content activity stays log-only by design. All endpoints are Site-Admin-only; covered by API DB tests and a Playwright pack in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
176 lines
6.0 KiB
TypeScript
176 lines
6.0 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma, User } from '@prisma/client';
|
|
import {
|
|
AUDIT_PAGE_SIZE,
|
|
BACKUP_FRESH_MAX_AGE_HOURS,
|
|
BACKUP_STATUS_FILE,
|
|
type AuditListQuery,
|
|
type AuditListView,
|
|
type BackupStatus,
|
|
type JobTriggerResult,
|
|
type StorageOverviewView,
|
|
type SystemBackupView,
|
|
type SystemJobView,
|
|
} from '@dorfteich/shared';
|
|
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { AppConfig } from '../config/app-config.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SchedulerService } from '../scheduler/scheduler.service';
|
|
|
|
/**
|
|
* Data behind the Site-Admin "System" panel (issue #86): maintenance jobs
|
|
* with truthful last-run data, the backup card mirroring the sidecar's
|
|
* status.json, the persistent audit trail, and the per-pond storage top
|
|
* list. Reads only — the single write path is the manual job trigger,
|
|
* which is itself audit-logged.
|
|
*/
|
|
@Injectable()
|
|
export class SystemAdminService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly scheduler: SchedulerService,
|
|
private readonly audit: AuditService,
|
|
private readonly config: AppConfig,
|
|
) {}
|
|
|
|
/**
|
|
* Registered jobs merged with their `jobs` rows: a job that never ran yet
|
|
* appears with null last-run data, and a leftover row whose job no longer
|
|
* registers in this build is flagged instead of hidden.
|
|
*/
|
|
async jobs(): Promise<SystemJobView[]> {
|
|
const definitions = this.scheduler.definitions();
|
|
const rows = await this.prisma.job.findMany();
|
|
const rowsByName = new Map(rows.map((row) => [row.name, row]));
|
|
const views: SystemJobView[] = definitions.map((definition) => {
|
|
const row = rowsByName.get(definition.name);
|
|
rowsByName.delete(definition.name);
|
|
return {
|
|
name: definition.name,
|
|
cadenceSeconds: definition.cadenceSeconds,
|
|
status: row?.status ?? 'IDLE',
|
|
lastRunAt: row?.lastRunAt?.toISOString() ?? null,
|
|
lastDurationMs: row?.lastDurationMs ?? null,
|
|
lastError: row?.lastError ?? null,
|
|
registered: true,
|
|
} satisfies SystemJobView;
|
|
});
|
|
for (const row of rowsByName.values()) {
|
|
views.push({
|
|
name: row.name,
|
|
cadenceSeconds: row.cadenceSeconds,
|
|
status: row.status,
|
|
lastRunAt: row.lastRunAt?.toISOString() ?? null,
|
|
lastDurationMs: row.lastDurationMs ?? null,
|
|
lastError: row.lastError ?? null,
|
|
registered: false,
|
|
});
|
|
}
|
|
return views.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async triggerJob(actor: User, name: string): Promise<JobTriggerResult> {
|
|
if (!this.scheduler.definitions().some((job) => job.name === name)) {
|
|
throw new NotFoundException();
|
|
}
|
|
const outcome = await this.scheduler.runNow(name);
|
|
await this.audit.record({
|
|
action: 'job.triggered',
|
|
actorId: actor.id,
|
|
targetType: 'job',
|
|
targetId: name,
|
|
details: { outcome },
|
|
});
|
|
const job = (await this.jobs()).find((view) => view.name === name)!;
|
|
return { outcome, job };
|
|
}
|
|
|
|
/** The backup card mirrors status.json including the freshness verdict. */
|
|
backup(): SystemBackupView {
|
|
const path = join(this.config.env.BACKUPS_DIR, BACKUP_STATUS_FILE);
|
|
let status: BackupStatus | null = null;
|
|
if (existsSync(path)) {
|
|
try {
|
|
status = JSON.parse(readFileSync(path, 'utf8')) as BackupStatus;
|
|
} catch {
|
|
status = null;
|
|
}
|
|
}
|
|
const finishedAt = status?.lastSuccess?.finishedAt;
|
|
const ageHours = finishedAt
|
|
? (Date.now() - new Date(finishedAt).getTime()) / 3_600_000
|
|
: Number.POSITIVE_INFINITY;
|
|
return {
|
|
available: status !== null,
|
|
fresh: Number.isFinite(ageHours) && ageHours <= BACKUP_FRESH_MAX_AGE_HOURS,
|
|
status,
|
|
maxAgeHours: BACKUP_FRESH_MAX_AGE_HOURS,
|
|
};
|
|
}
|
|
|
|
async auditLog(query: AuditListQuery): Promise<AuditListView> {
|
|
const where: Prisma.AuditEntryWhereInput = {};
|
|
if (query.actor) {
|
|
const actor = await this.prisma.user.findUnique({ where: { username: query.actor } });
|
|
// An unknown username matches nothing rather than everything.
|
|
where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000';
|
|
}
|
|
if (query.action) where.action = { startsWith: query.action };
|
|
if (query.from || query.to) {
|
|
where.at = {
|
|
...(query.from ? { gte: query.from } : {}),
|
|
...(query.to ? { lte: query.to } : {}),
|
|
};
|
|
}
|
|
|
|
const total = await this.prisma.auditEntry.count({ where });
|
|
const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
|
|
const page = Math.min(query.page, pageCount);
|
|
const entries = await this.prisma.auditEntry.findMany({
|
|
where,
|
|
orderBy: { at: 'desc' },
|
|
skip: (page - 1) * AUDIT_PAGE_SIZE,
|
|
take: AUDIT_PAGE_SIZE,
|
|
include: { actor: { select: { id: true, username: true, displayName: true } } },
|
|
});
|
|
return {
|
|
entries: entries.map((entry) => ({
|
|
id: entry.id,
|
|
at: entry.at.toISOString(),
|
|
action: entry.action,
|
|
actor: entry.actor,
|
|
targetType: entry.targetType,
|
|
targetId: entry.targetId,
|
|
details: (entry.details as Record<string, unknown> | null) ?? null,
|
|
})),
|
|
page,
|
|
pageCount,
|
|
total,
|
|
};
|
|
}
|
|
|
|
async storage(): Promise<StorageOverviewView> {
|
|
const usages = await this.prisma.pondUsage.findMany({
|
|
where: { pond: { deletedAt: null } },
|
|
orderBy: { storageBytesUsed: 'desc' },
|
|
take: 20,
|
|
include: { pond: { select: { name: true, slug: true, type: true } } },
|
|
});
|
|
const totals = await this.prisma.pondUsage.aggregate({ _sum: { storageBytesUsed: true } });
|
|
return {
|
|
totalBytes: Number(totals._sum.storageBytesUsed ?? 0n),
|
|
ponds: usages.map((usage) => ({
|
|
pondId: usage.pondId,
|
|
name: usage.pond.name,
|
|
slug: usage.pond.slug,
|
|
type: usage.pond.type === 'PERSONAL' ? 'personal' : 'shared',
|
|
storageBytesUsed: Number(usage.storageBytesUsed),
|
|
})),
|
|
};
|
|
}
|
|
}
|