253 lines
9.2 KiB
JavaScript
253 lines
9.2 KiB
JavaScript
import {
|
|
adminDeleteUploadAction,
|
|
adminExtendUploadAction,
|
|
adminLogoutAction,
|
|
} from '@/src/lib/actions.js';
|
|
import { adminHash } from '@/src/lib/config.js';
|
|
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
|
|
import { sharedLinkName } from '@/src/lib/files.js';
|
|
import {
|
|
formatBytes,
|
|
formatCountdown,
|
|
formatTimestamp,
|
|
parseLogDetail,
|
|
readSearchParam,
|
|
} from '@/src/lib/format.js';
|
|
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
|
|
|
import { CopyLinkButton } from '../../_components/copy-link-button.js';
|
|
import { StatusMessage } from '../../_components/status-message.js';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export default async function AdminDashboardPage({ searchParams }) {
|
|
await runCleanupIfNeeded();
|
|
|
|
if (!adminHash) {
|
|
return (
|
|
<main className="page-shell narrow">
|
|
<section className="panel centered">
|
|
<h1>Adminzugang nicht konfiguriert</h1>
|
|
<p className="muted">Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.</p>
|
|
<a className="btn secondary" href="/manage/login">
|
|
Zurück
|
|
</a>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
await requireAdminUser();
|
|
const csrfToken = await ensureCsrfToken();
|
|
|
|
const [
|
|
activeCount,
|
|
activeBytes,
|
|
distinctOwners,
|
|
totalUploads,
|
|
totalDownloads,
|
|
totalDeletes,
|
|
lastCleanup,
|
|
recentLogs,
|
|
allUploads,
|
|
] = await Promise.all([
|
|
get('SELECT COUNT(*) AS count FROM uploads'),
|
|
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
|
|
get('SELECT COUNT(DISTINCT owner) AS count FROM uploads'),
|
|
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event = ?', ['upload']),
|
|
get('SELECT COALESCE(SUM(downloads), 0) AS count FROM uploads'),
|
|
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event IN (?, ?, ?)', [
|
|
'delete',
|
|
'cleanup',
|
|
'admin_delete',
|
|
]),
|
|
get('SELECT MAX(created_at) AS ts FROM admin_logs WHERE event = ?', ['cleanup']),
|
|
all(
|
|
'SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 250'
|
|
),
|
|
all(
|
|
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
|
|
),
|
|
]);
|
|
|
|
const resolvedSearchParams = await searchParams;
|
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
|
|
|
return (
|
|
<main className="page-shell">
|
|
<header className="page-header">
|
|
<div className="header-main">
|
|
<h1>Adminübersicht</h1>
|
|
<p className="muted">Metriken, Ereignisse und direkte Eingriffe in Uploads.</p>
|
|
</div>
|
|
|
|
<div className="toolbar">
|
|
<a className="chip primary" href="/manage/admin/files">
|
|
Dateimanager
|
|
</a>
|
|
<a className="chip primary" href="/manage/admin/users">
|
|
Benutzer verwalten
|
|
</a>
|
|
<form className="inline-form" action={adminLogoutAction}>
|
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
<button className="btn secondary" type="submit">
|
|
Abmelden
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
|
|
<StatusMessage error={error} success={success} />
|
|
|
|
<section className="panel">
|
|
<h2>Statistiken</h2>
|
|
<div className="metric-grid">
|
|
<article className="metric">
|
|
<span className="muted">Aktive Uploads</span>
|
|
<strong>{activeCount?.count || 0}</strong>
|
|
</article>
|
|
<article className="metric">
|
|
<span className="muted">Aktive Dateigröße</span>
|
|
<strong>{formatBytes(activeBytes?.total || 0)}</strong>
|
|
</article>
|
|
<article className="metric">
|
|
<span className="muted">Aktive Nutzer</span>
|
|
<strong>{distinctOwners?.count || 0}</strong>
|
|
</article>
|
|
<article className="metric">
|
|
<span className="muted">Uploads gesamt</span>
|
|
<strong>{totalUploads?.count || 0}</strong>
|
|
</article>
|
|
<article className="metric">
|
|
<span className="muted">Downloads gesamt</span>
|
|
<strong>{totalDownloads?.count || 0}</strong>
|
|
</article>
|
|
<article className="metric">
|
|
<span className="muted">Löschungen gesamt</span>
|
|
<strong>{totalDeletes?.count || 0}</strong>
|
|
</article>
|
|
<article className="metric">
|
|
<span className="muted">Letztes Cleanup</span>
|
|
<strong>{lastCleanup?.ts ? formatTimestamp(lastCleanup.ts) : '-'}</strong>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<h2>Aktuelle Uploads</h2>
|
|
{allUploads.length === 0 ? (
|
|
<p className="muted">Noch keine Uploads vorhanden.</p>
|
|
) : (
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Nutzer</th>
|
|
<th>Datei</th>
|
|
<th>Größe</th>
|
|
<th>Hochgeladen</th>
|
|
<th>Ablauf</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allUploads.map((item) => {
|
|
const shareName = sharedLinkName(item.stored_name);
|
|
const sharePath = `/_share/${encodeURIComponent(shareName)}`;
|
|
return (
|
|
<tr key={item.id}>
|
|
<td>{item.owner}</td>
|
|
<td>
|
|
<strong>{item.original_name}</strong>
|
|
<div className="muted mono">{item.stored_name}</div>
|
|
</td>
|
|
<td>{formatBytes(item.size_bytes)}</td>
|
|
<td>{formatTimestamp(item.uploaded_at)}</td>
|
|
<td>
|
|
<div>{formatTimestamp(item.expires_at)}</div>
|
|
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
|
|
</td>
|
|
<td>
|
|
<div className="stack-actions">
|
|
<div className="row-actions">
|
|
<a className="btn secondary" href={sharePath}>
|
|
Download
|
|
</a>
|
|
<CopyLinkButton path={sharePath} label={item.original_name} />
|
|
</div>
|
|
|
|
<form className="inline-form action-form-row">
|
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
<input type="hidden" name="uploadId" value={item.id} />
|
|
<input className="input small" name="extendHours" placeholder="Stunden" />
|
|
<button className="btn" type="submit" formAction={adminExtendUploadAction}>
|
|
Verlängern
|
|
</button>
|
|
<button className="btn danger" type="submit" formAction={adminDeleteUploadAction}>
|
|
Löschen
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<h2>Letzte Ereignisse</h2>
|
|
{recentLogs.length === 0 ? (
|
|
<p className="muted">Keine Logs vorhanden.</p>
|
|
) : (
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Zeit</th>
|
|
<th>Event</th>
|
|
<th>Nutzer</th>
|
|
<th>IP</th>
|
|
<th>Details</th>
|
|
<th>User-Agent</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentLogs.map((entry, index) => {
|
|
const details = parseLogDetail(entry.detail);
|
|
return (
|
|
<tr key={`${entry.created_at}-${entry.event}-${index}`}>
|
|
<td>{formatTimestamp(entry.created_at)}</td>
|
|
<td>{entry.event}</td>
|
|
<td>{entry.owner || '-'}</td>
|
|
<td className="mono">{entry.ip || '-'}</td>
|
|
<td>
|
|
{details.length === 0 ? (
|
|
<span className="muted">-</span>
|
|
) : (
|
|
<div className="stack-actions">
|
|
{details.map((detail) => (
|
|
<div key={`${detail.key}-${detail.value}`}>
|
|
<strong>{detail.key}:</strong> <span>{detail.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="mono">{entry.user_agent || '-'}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|