2026-06-17 17:06:30 -06:00
|
|
|
/**
|
2026-06-17 22:07:18 -06:00
|
|
|
* Admin endpoints — ported from views/admin/admin.js. Covers users, plugins
|
|
|
|
|
* (incl. logs/retention/live SSE tail), dashboard, settings (OIDC/storage/SMTP),
|
|
|
|
|
* and storage migration (incl. the verify integrity check).
|
2026-06-17 17:06:30 -06:00
|
|
|
*/
|
|
|
|
|
import { apiFetch, apiJson } from '$lib/api/client';
|
|
|
|
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
2026-06-24 01:20:44 +02:00
|
|
|
import type { Drive, DriveMember, DriveMemberSubject, DriveRole, User } from '$lib/api/types';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
|
|
|
|
|
|
|
|
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
|
|
|
|
|
const res = await apiFetch(url, {
|
|
|
|
|
method,
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
|
|
|
|
throw new Error(e.message || `${method} ${url} failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:13:18 +02:00
|
|
|
/** POST with no request body that returns a JSON payload (throws on non-2xx). */
|
|
|
|
|
async function postJson<T>(url: string): Promise<T> {
|
|
|
|
|
const res = await apiFetch(url, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() }
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
|
|
|
|
throw new Error(e.message || `POST ${url} failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
return (await res.json()) as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Maintenance ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** Outcome of a bulk metadata re-extraction run. */
|
|
|
|
|
export interface ReextractResult {
|
|
|
|
|
message: string;
|
|
|
|
|
total: number;
|
|
|
|
|
processed: number;
|
|
|
|
|
failed: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Re-scan every audio file and backfill its tag metadata (idempotent). */
|
|
|
|
|
export function reextractAudioMetadata(): Promise<ReextractResult> {
|
|
|
|
|
return postJson<ReextractResult>('/api/admin/audio/metadata/reextract');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Backfill EXIF / container capture dates for all media, re-bucketing the
|
|
|
|
|
* Photos timeline by real capture date (idempotent). */
|
|
|
|
|
export function reextractPhotoMetadata(): Promise<ReextractResult> {
|
|
|
|
|
return postJson<ReextractResult>('/api/admin/photos/metadata/reextract');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A freshly generated AES-256 at-rest blob-encryption key (base64) plus a
|
|
|
|
|
* data-loss warning authored by the server. */
|
|
|
|
|
export interface GeneratedKey {
|
|
|
|
|
key: string;
|
|
|
|
|
warning: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Generate a random AES-256 key for at-rest blob encryption. */
|
|
|
|
|
export function generateEncryptionKey(): Promise<GeneratedKey> {
|
|
|
|
|
return postJson<GeneratedKey>('/api/admin/settings/storage/generate-key');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 01:20:44 +02:00
|
|
|
// ── Drives ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `GET /api/admin/drives` — every drive on the system, admin-only.
|
|
|
|
|
*
|
|
|
|
|
* Distinct from `listDrives()` in `$lib/api/endpoints/drives`, which is
|
|
|
|
|
* the caller's own listing (filtered through `role_grants`). An admin
|
|
|
|
|
* who creates a shared drive for someone else has no role on it, so
|
|
|
|
|
* the user-facing listing would skip it — this endpoint returns
|
|
|
|
|
* everything for the admin panel's "Drives" tab.
|
|
|
|
|
*/
|
|
|
|
|
export function listAllDrives(): Promise<Drive[]> {
|
|
|
|
|
return apiJson<Drive[]>('/api/admin/drives', { credentials: 'same-origin' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `GET /api/admin/drives/{id}/members` — every role grant on a drive,
|
|
|
|
|
* admin-only. The user-facing `/api/drives/{id}/members` requires
|
|
|
|
|
* `Permission::Read` on the drive; an admin who created the drive
|
|
|
|
|
* for someone else has no role on it and would hit a 404 there. This
|
|
|
|
|
* endpoint reuses `list_grants_on_resource` with the admin guard at
|
|
|
|
|
* the route edge, so the same `DriveMember` shape comes back.
|
|
|
|
|
*/
|
|
|
|
|
export function listDriveMembersAdmin(driveId: string): Promise<DriveMember[]> {
|
|
|
|
|
return apiJson<DriveMember[]>(`/api/admin/drives/${encodeURIComponent(driveId)}/members`, {
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `POST /api/admin/drives/{id}/members` — add (or refresh) a member as
|
|
|
|
|
* an admin, bypassing the per-drive `Manage` check. Personal-drive
|
|
|
|
|
* guard + last-owner protection still apply. Throws on non-2xx.
|
|
|
|
|
*/
|
|
|
|
|
export async function addDriveMemberAdmin(
|
|
|
|
|
driveId: string,
|
|
|
|
|
subject: DriveMemberSubject,
|
|
|
|
|
role: DriveRole,
|
|
|
|
|
expiresAt?: string | null
|
|
|
|
|
): Promise<DriveMember> {
|
|
|
|
|
const res = await apiFetch(`/api/admin/drives/${encodeURIComponent(driveId)}/members`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ subject, role, expires_at: expiresAt ?? null })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
let detail = '';
|
|
|
|
|
try {
|
|
|
|
|
const parsed = (await res.json()) as { error?: string; message?: string };
|
|
|
|
|
detail = parsed.error ?? parsed.message ?? '';
|
|
|
|
|
} catch {
|
|
|
|
|
/* response body wasn't JSON */
|
|
|
|
|
}
|
|
|
|
|
throw new Error(detail || `add member failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
return (await res.json()) as DriveMember;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `DELETE /api/admin/drives/{id}/members/{kind}/{sid}` — remove a
|
|
|
|
|
* member as an admin. Idempotent (removing a non-member returns 204).
|
|
|
|
|
* Last-owner protection still applies (400 with `reason='last_owner'`).
|
|
|
|
|
*/
|
|
|
|
|
export async function removeDriveMemberAdmin(
|
|
|
|
|
driveId: string,
|
|
|
|
|
subject: DriveMemberSubject
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const url =
|
|
|
|
|
`/api/admin/drives/${encodeURIComponent(driveId)}/members/` +
|
|
|
|
|
`${encodeURIComponent(subject.type)}/${encodeURIComponent(subject.id)}`;
|
|
|
|
|
const res = await apiFetch(url, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: getCsrfHeaders()
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
let detail = '';
|
|
|
|
|
try {
|
|
|
|
|
const parsed = (await res.json()) as { error?: string; message?: string };
|
|
|
|
|
detail = parsed.error ?? parsed.message ?? '';
|
|
|
|
|
} catch {
|
|
|
|
|
/* response body wasn't JSON */
|
|
|
|
|
}
|
|
|
|
|
throw new Error(detail || `remove member failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
// ── Users ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface AdminUsersPage {
|
|
|
|
|
total: number;
|
|
|
|
|
users: User[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listUsers(limit: number, offset: number): Promise<AdminUsersPage> {
|
|
|
|
|
return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}`, {
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateUserInput {
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
2026-06-17 22:07:18 -06:00
|
|
|
/** Optional — the backend auto-generates an address when null/empty. */
|
|
|
|
|
email: string | null;
|
2026-06-17 17:06:30 -06:00
|
|
|
role: string;
|
|
|
|
|
quota_bytes: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createUser(input: CreateUserInput): Promise<void> {
|
|
|
|
|
return mutate('/api/admin/users', 'POST', input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setUserRole(userId: string, role: string): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/users/${userId}/role`, 'PUT', { role });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setUserActive(userId: string, active: boolean): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/users/${userId}/active`, 'PUT', { active });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setUserQuota(userId: string, quotaBytes: number): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/users/${userId}/quota`, 'PUT', { quota_bytes: quotaBytes });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resetUserPassword(userId: string, newPassword: string): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/users/${userId}/password`, 'PUT', { new_password: newPassword });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deleteUser(userId: string): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/users/${userId}`, 'DELETE');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
// ── Dashboard ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface AdminDashboard {
|
|
|
|
|
total_users: number;
|
|
|
|
|
active_users: number;
|
|
|
|
|
admin_users: number;
|
|
|
|
|
server_version: string;
|
|
|
|
|
total_used_bytes: number;
|
|
|
|
|
total_quota_bytes: number;
|
|
|
|
|
storage_usage_percent: number;
|
|
|
|
|
auth_enabled: boolean;
|
|
|
|
|
oidc_configured: boolean;
|
|
|
|
|
quotas_enabled: boolean;
|
|
|
|
|
registration_enabled?: boolean;
|
|
|
|
|
users_over_80_percent: number;
|
|
|
|
|
users_over_quota: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getDashboard(): Promise<AdminDashboard> {
|
|
|
|
|
return apiJson<AdminDashboard>('/api/admin/dashboard', { credentials: 'same-origin' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setRegistrationEnabled(enabled: boolean): Promise<void> {
|
|
|
|
|
return mutate('/api/admin/settings/registration', 'PUT', { registration_enabled: enabled });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── SMTP ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface SmtpInfo {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
host: string;
|
|
|
|
|
port: number;
|
|
|
|
|
tls: string;
|
|
|
|
|
from: string;
|
|
|
|
|
user_state: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getSmtpInfo(): Promise<SmtpInfo> {
|
|
|
|
|
return apiJson<SmtpInfo>('/api/admin/smtp/info', { credentials: 'same-origin' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SmtpTestResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
code?: string | number;
|
|
|
|
|
message?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Result of POST .../settings/storage/test — the S3 connection probe. */
|
|
|
|
|
export interface StorageTestResult {
|
|
|
|
|
connected?: boolean;
|
|
|
|
|
success?: boolean;
|
|
|
|
|
backend_type?: string;
|
|
|
|
|
available_bytes?: number | null;
|
|
|
|
|
message?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function sendSmtpTest(to: string): Promise<SmtpTestResult> {
|
|
|
|
|
const res = await apiFetch('/api/admin/smtp/test', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ to })
|
|
|
|
|
});
|
|
|
|
|
if (res.status === 503)
|
|
|
|
|
return { success: false, message: 'SMTP is not configured on this server.' };
|
|
|
|
|
return (await res.json().catch(() => ({ success: false }))) as SmtpTestResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── OIDC settings ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface OidcSettings {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
issuer_url: string;
|
|
|
|
|
client_id: string;
|
|
|
|
|
scopes: string | null;
|
|
|
|
|
auto_provision: boolean;
|
|
|
|
|
admin_groups: string | null;
|
|
|
|
|
disable_password_login: boolean;
|
|
|
|
|
provider_name: string | null;
|
|
|
|
|
callback_url?: string;
|
|
|
|
|
client_secret_set?: boolean;
|
|
|
|
|
env_overrides?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OidcTestResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
issuer?: string;
|
|
|
|
|
authorization_endpoint?: string;
|
|
|
|
|
provider_name_suggestion?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getOidcSettings(): Promise<OidcSettings> {
|
|
|
|
|
return apiJson<OidcSettings>('/api/admin/settings/oidc', { credentials: 'same-origin' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function testOidc(issuerUrl: string): Promise<OidcTestResult> {
|
|
|
|
|
const res = await apiFetch('/api/admin/settings/oidc/test', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ issuer_url: issuerUrl })
|
|
|
|
|
});
|
|
|
|
|
return (await res
|
|
|
|
|
.json()
|
|
|
|
|
.catch(() => ({ success: false, message: 'Request failed' }))) as OidcTestResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function saveOidc(body: Record<string, unknown>): Promise<void> {
|
|
|
|
|
return mutate('/api/admin/settings/oidc', 'PUT', body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Storage settings + migration ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface StorageSettings {
|
|
|
|
|
backend: string;
|
|
|
|
|
s3_endpoint_url?: string | null;
|
|
|
|
|
s3_bucket?: string | null;
|
|
|
|
|
s3_region?: string | null;
|
|
|
|
|
s3_access_key_set?: boolean;
|
|
|
|
|
s3_secret_key_set?: boolean;
|
|
|
|
|
s3_force_path_style?: boolean;
|
|
|
|
|
env_overrides?: string[];
|
|
|
|
|
current_backend?: string;
|
|
|
|
|
total_blobs?: number;
|
|
|
|
|
total_bytes_stored?: number;
|
|
|
|
|
dedup_ratio?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getStorageSettings(): Promise<StorageSettings> {
|
|
|
|
|
return apiJson<StorageSettings>('/api/admin/settings/storage', { credentials: 'same-origin' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function saveStorage(body: Record<string, unknown>): Promise<void> {
|
|
|
|
|
return mutate('/api/admin/settings/storage', 'PUT', body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function testStorage(body: Record<string, unknown>): Promise<StorageTestResult> {
|
|
|
|
|
const res = await apiFetch('/api/admin/settings/storage/test', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify(body)
|
|
|
|
|
});
|
|
|
|
|
return (await res.json().catch(() => ({ connected: false }))) as StorageTestResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MigrationStatus {
|
|
|
|
|
status: 'idle' | 'running' | 'paused' | 'completed' | 'failed';
|
|
|
|
|
total_blobs: number;
|
|
|
|
|
migrated_blobs: number;
|
|
|
|
|
migrated_bytes: number;
|
|
|
|
|
throughput_bytes_per_sec?: number;
|
|
|
|
|
failed_blobs?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getMigration(): Promise<MigrationStatus> {
|
|
|
|
|
return apiJson<MigrationStatus>('/api/admin/storage/migration', { credentials: 'same-origin' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function migrationAction(action: 'start' | 'pause' | 'resume' | 'complete'): Promise<void> {
|
|
|
|
|
const body = action === 'start' ? { concurrency: 4 } : {};
|
|
|
|
|
return mutate(`/api/admin/storage/migration/${action}`, 'POST', body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Result of a `verify` integrity check (POST .../migration/verify). */
|
|
|
|
|
export interface MigrationVerifyResult {
|
|
|
|
|
passed: boolean;
|
|
|
|
|
sample_checked: number;
|
|
|
|
|
pg_blob_count: number;
|
|
|
|
|
missing_in_target: string[];
|
|
|
|
|
size_mismatches: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run an integrity verification pass over a sample of migrated blobs. Unlike
|
|
|
|
|
* the other migration actions this returns a structured result that the caller
|
|
|
|
|
* renders (passed / sample-checked / missing / size-mismatch counts).
|
|
|
|
|
*/
|
|
|
|
|
export async function verifyMigration(sampleSize = 100): Promise<MigrationVerifyResult> {
|
|
|
|
|
const res = await apiFetch('/api/admin/storage/migration/verify', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ sample_size: sampleSize })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
|
|
|
|
throw new Error(e.message || `verify failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
const r = (await res.json()) as Partial<MigrationVerifyResult>;
|
|
|
|
|
return {
|
|
|
|
|
passed: r.passed ?? false,
|
|
|
|
|
sample_checked: r.sample_checked ?? 0,
|
|
|
|
|
pg_blob_count: r.pg_blob_count ?? 0,
|
|
|
|
|
missing_in_target: r.missing_in_target ?? [],
|
|
|
|
|
size_mismatches: r.size_mismatches ?? []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
// ── Plugins ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PluginInfo {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
version?: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
description?: string;
|
2026-06-17 22:07:18 -06:00
|
|
|
abi?: string | number;
|
|
|
|
|
subscriptions?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PluginRetention {
|
|
|
|
|
retention_days: number;
|
|
|
|
|
max_bytes: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install a plugin from a .zip bundle. The browser sets the multipart
|
|
|
|
|
* Content-Type (with boundary) — do not override it here.
|
|
|
|
|
*/
|
|
|
|
|
export async function installPlugin(bundle: File): Promise<PluginInfo> {
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
form.append('bundle', bundle);
|
|
|
|
|
const res = await apiFetch('/api/admin/plugins', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...getCsrfHeaders() },
|
|
|
|
|
body: form
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
|
|
|
|
throw new Error(e.message || `install failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
return (await res.json()) as PluginInfo;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPluginRetention(id: string): Promise<PluginRetention | null> {
|
|
|
|
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(id)}/retention`, {
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
return (await res.json()) as PluginRetention;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function savePluginRetention(id: string, r: PluginRetention): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/retention`, 'PUT', r);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearPluginLogs(id: string): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/logs`, 'DELETE');
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PluginsResult {
|
|
|
|
|
/** false when the plugin subsystem is disabled (server returns 503). */
|
|
|
|
|
available: boolean;
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
plugins: PluginInfo[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listPlugins(): Promise<PluginsResult> {
|
|
|
|
|
const res = await apiFetch('/api/admin/plugins', { credentials: 'same-origin' });
|
|
|
|
|
if (res.status === 503) return { available: false, plugins: [] };
|
|
|
|
|
if (!res.ok) throw new Error(`plugins failed: ${res.status}`);
|
|
|
|
|
const data = (await res.json()) as { enabled?: boolean; plugins?: PluginInfo[] };
|
|
|
|
|
return { available: true, enabled: data.enabled, plugins: data.plugins ?? [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/enabled`, 'PUT', { enabled });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deletePlugin(id: string): Promise<void> {
|
|
|
|
|
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}`, 'DELETE');
|
|
|
|
|
}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
export interface PluginLogEntry {
|
|
|
|
|
timestamp?: string;
|
|
|
|
|
ts?: string;
|
|
|
|
|
level?: string;
|
|
|
|
|
message?: string;
|
|
|
|
|
/** Streamed-entry message field (SSE / persisted logs use `msg`). */
|
|
|
|
|
msg?: string;
|
|
|
|
|
/** "outcome" | "log" — outcome entries carry a `reason`. */
|
|
|
|
|
kind?: string;
|
|
|
|
|
reason?: string;
|
|
|
|
|
invocation_id?: string;
|
|
|
|
|
[k: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PluginLogPage {
|
|
|
|
|
total: number;
|
|
|
|
|
entries: PluginLogEntry[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getPluginLogs(
|
|
|
|
|
id: string,
|
|
|
|
|
opts: { limit?: number; offset?: number; level?: string; search?: string } = {}
|
|
|
|
|
): Promise<PluginLogPage> {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
params.set('limit', String(opts.limit ?? 50));
|
|
|
|
|
params.set('offset', String(opts.offset ?? 0));
|
|
|
|
|
if (opts.level) params.set('level', opts.level);
|
|
|
|
|
if (opts.search) params.set('search', opts.search);
|
|
|
|
|
return apiJson<PluginLogPage>(`/api/admin/plugins/${encodeURIComponent(id)}/logs?${params}`, {
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
}
|