2026-06-17 17:06:30 -06:00
|
|
|
|
<script lang="ts">
|
2026-06-17 22:30:56 -06:00
|
|
|
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
clearPluginLogs,
|
2026-06-17 17:06:30 -06:00
|
|
|
|
createUser,
|
|
|
|
|
|
deletePlugin,
|
|
|
|
|
|
deleteUser,
|
2026-06-17 22:07:18 -06:00
|
|
|
|
getDashboard,
|
|
|
|
|
|
getMigration,
|
|
|
|
|
|
getOidcSettings,
|
|
|
|
|
|
getPluginLogs,
|
|
|
|
|
|
getPluginRetention,
|
|
|
|
|
|
getSmtpInfo,
|
|
|
|
|
|
getStorageSettings,
|
|
|
|
|
|
installPlugin,
|
2026-06-17 17:06:30 -06:00
|
|
|
|
listPlugins,
|
|
|
|
|
|
listUsers,
|
2026-06-17 22:07:18 -06:00
|
|
|
|
migrationAction,
|
2026-06-17 17:06:30 -06:00
|
|
|
|
resetUserPassword,
|
2026-06-17 22:07:18 -06:00
|
|
|
|
saveOidc,
|
|
|
|
|
|
savePluginRetention,
|
|
|
|
|
|
saveStorage,
|
|
|
|
|
|
sendSmtpTest,
|
2026-06-17 17:06:30 -06:00
|
|
|
|
setPluginEnabled,
|
2026-06-17 22:07:18 -06:00
|
|
|
|
setRegistrationEnabled,
|
2026-06-17 17:06:30 -06:00
|
|
|
|
setUserActive,
|
|
|
|
|
|
setUserQuota,
|
|
|
|
|
|
setUserRole,
|
2026-06-17 22:07:18 -06:00
|
|
|
|
testOidc,
|
|
|
|
|
|
testStorage,
|
|
|
|
|
|
verifyMigration,
|
|
|
|
|
|
type AdminDashboard,
|
|
|
|
|
|
type MigrationStatus,
|
|
|
|
|
|
type MigrationVerifyResult,
|
|
|
|
|
|
type OidcSettings,
|
|
|
|
|
|
type OidcTestResult,
|
|
|
|
|
|
type PluginInfo,
|
|
|
|
|
|
type PluginLogEntry,
|
|
|
|
|
|
type PluginRetention,
|
|
|
|
|
|
type SmtpInfo,
|
|
|
|
|
|
type SmtpTestResult,
|
|
|
|
|
|
type StorageSettings,
|
|
|
|
|
|
type StorageTestResult
|
2026-06-17 17:06:30 -06:00
|
|
|
|
} from '$lib/api/endpoints/admin';
|
|
|
|
|
|
import type { User } from '$lib/api/types';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
import Icon from '$lib/icons/Icon.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
|
|
|
|
import { t } from '$lib/i18n/index.svelte';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
import { session } from '$lib/stores/session.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import { ui } from '$lib/stores/ui.svelte';
|
|
|
|
|
|
import { formatBytes } from '$lib/utils/format';
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 25;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
const LOGS_PAGE_SIZE = 50;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/** The signed-in admin's id — used to disable destructive actions on self. */
|
|
|
|
|
|
const currentAdminId = $derived(session.user?.id ?? '');
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Render an ISO timestamp as a coarse relative time ("3 min ago"). Ported
|
|
|
|
|
|
* from formatRelativeTime in static/js/core/formatters.js; an empty/missing
|
|
|
|
|
|
* value reads as "Never" (matching the OLD admin user table).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function timeAgo(dateStr?: string | null): string {
|
|
|
|
|
|
if (!dateStr) return t('admin.never', 'Never');
|
|
|
|
|
|
const then = new Date(dateStr).getTime();
|
|
|
|
|
|
if (!Number.isFinite(then)) return t('admin.never', 'Never');
|
|
|
|
|
|
const secs = Math.round((Date.now() - then) / 1000);
|
|
|
|
|
|
if (secs < 60) return t('admin.time_just_now', 'just now');
|
|
|
|
|
|
const mins = Math.round(secs / 60);
|
|
|
|
|
|
if (mins < 60) return t('admin.time_min_ago', { n: mins }, '{{n}} min ago');
|
|
|
|
|
|
const hours = Math.round(mins / 60);
|
|
|
|
|
|
if (hours < 24) return t('admin.time_hour_ago', { n: hours }, '{{n}} h ago');
|
|
|
|
|
|
const days = Math.round(hours / 24);
|
|
|
|
|
|
if (days < 30) return t('admin.time_day_ago', { n: days }, '{{n}} d ago');
|
|
|
|
|
|
return new Date(dateStr).toLocaleDateString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Quota unit options (bytes per unit) for the quota/create modals. */
|
|
|
|
|
|
const QUOTA_UNITS = [
|
|
|
|
|
|
{ value: 1024 ** 2, label: 'MB' },
|
|
|
|
|
|
{ value: 1024 ** 3, label: 'GB' },
|
|
|
|
|
|
{ value: 1024 ** 4, label: 'TB' }
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Styled confirm modal (replaces native confirm) ── */
|
|
|
|
|
|
let confirmState = $state<{ message: string; resolve: (ok: boolean) => void } | null>(null);
|
|
|
|
|
|
function showConfirm(message: string): Promise<boolean> {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
confirmState = { message, resolve };
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
function resolveConfirm(ok: boolean) {
|
|
|
|
|
|
confirmState?.resolve(ok);
|
|
|
|
|
|
confirmState = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Tab = 'dashboard' | 'users' | 'plugins' | 'oidc' | 'storage' | 'smtp';
|
|
|
|
|
|
let tab = $state<Tab>('dashboard');
|
|
|
|
|
|
|
|
|
|
|
|
// Dashboard
|
|
|
|
|
|
let dashboard = $state<AdminDashboard | null>(null);
|
|
|
|
|
|
let dashboardError = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// SMTP
|
|
|
|
|
|
let smtp = $state<SmtpInfo | null>(null);
|
|
|
|
|
|
let smtpTo = $state('');
|
|
|
|
|
|
let smtpResult = $state<SmtpTestResult | null>(null);
|
|
|
|
|
|
let smtpSending = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadDashboard() {
|
|
|
|
|
|
dashboardError = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
dashboard = await getDashboard();
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
dashboardError = errorMessage(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleRegistration(enabled: boolean) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setRegistrationEnabled(enabled);
|
|
|
|
|
|
if (dashboard) dashboard.registration_enabled = enabled;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
await loadDashboard();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSmtp() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
smtp = await getSmtpInfo();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runSmtpTest() {
|
|
|
|
|
|
if (!smtpTo.trim()) return;
|
|
|
|
|
|
smtpSending = true;
|
|
|
|
|
|
smtpResult = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
smtpResult = await sendSmtpTest(smtpTo.trim());
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
smtpResult = { success: false, message: errorMessage(e) };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
smtpSending = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OIDC
|
|
|
|
|
|
let oidc = $state<(OidcSettings & { client_secret?: string }) | null>(null);
|
|
|
|
|
|
let oidcTest = $state<OidcTestResult | null>(null);
|
|
|
|
|
|
let oidcMsg = $state<{ text: string; ok: boolean } | null>(null);
|
|
|
|
|
|
let oidcSaving = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadOidc() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
oidc = await getOidcSettings();
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
oidcMsg = { text: errorMessage(e), ok: false };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function runOidcTest() {
|
|
|
|
|
|
if (!oidc?.issuer_url) return;
|
|
|
|
|
|
oidcTest = await testOidc(oidc.issuer_url);
|
|
|
|
|
|
if (oidcTest.success && oidcTest.provider_name_suggestion && !oidc.provider_name) {
|
|
|
|
|
|
oidc.provider_name = oidcTest.provider_name_suggestion;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function doSaveOidc() {
|
|
|
|
|
|
if (!oidc) return;
|
|
|
|
|
|
oidcSaving = true;
|
|
|
|
|
|
oidcMsg = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await saveOidc({
|
|
|
|
|
|
enabled: oidc.enabled,
|
|
|
|
|
|
issuer_url: oidc.issuer_url.trim(),
|
|
|
|
|
|
client_id: oidc.client_id.trim(),
|
|
|
|
|
|
client_secret: oidc.client_secret || null,
|
|
|
|
|
|
scopes: oidc.scopes || null,
|
|
|
|
|
|
auto_provision: oidc.auto_provision,
|
|
|
|
|
|
admin_groups: oidc.admin_groups || null,
|
|
|
|
|
|
disable_password_login: oidc.disable_password_login,
|
|
|
|
|
|
provider_name: oidc.provider_name || null
|
|
|
|
|
|
});
|
|
|
|
|
|
oidcMsg = { text: t('admin.settings_saved_ok', 'Settings saved.'), ok: true };
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
oidcMsg = { text: errorMessage(e), ok: false };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
oidcSaving = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Storage
|
|
|
|
|
|
const STORAGE_PRESETS: Record<string, { endpoint: string; region: string; pathStyle: boolean }> =
|
|
|
|
|
|
{
|
|
|
|
|
|
custom: { endpoint: '', region: '', pathStyle: false },
|
|
|
|
|
|
aws: { endpoint: '', region: 'us-east-1', pathStyle: false },
|
|
|
|
|
|
backblaze: {
|
|
|
|
|
|
endpoint: 'https://s3.{region}.backblazeb2.com',
|
|
|
|
|
|
region: 'us-west-004',
|
|
|
|
|
|
pathStyle: false
|
|
|
|
|
|
},
|
|
|
|
|
|
'cloudflare-r2': {
|
|
|
|
|
|
endpoint: 'https://{accountId}.r2.cloudflarestorage.com',
|
|
|
|
|
|
region: 'auto',
|
|
|
|
|
|
pathStyle: true
|
|
|
|
|
|
},
|
|
|
|
|
|
minio: { endpoint: 'http://localhost:9000', region: 'us-east-1', pathStyle: true },
|
|
|
|
|
|
digitalocean: {
|
|
|
|
|
|
endpoint: 'https://{region}.digitaloceanspaces.com',
|
|
|
|
|
|
region: 'nyc3',
|
|
|
|
|
|
pathStyle: false
|
|
|
|
|
|
},
|
|
|
|
|
|
wasabi: {
|
|
|
|
|
|
endpoint: 'https://s3.{region}.wasabisys.com',
|
|
|
|
|
|
region: 'us-east-1',
|
|
|
|
|
|
pathStyle: false
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let storage = $state<StorageSettings | null>(null);
|
|
|
|
|
|
let sForm = $state({
|
|
|
|
|
|
backend: 'local',
|
|
|
|
|
|
preset: 'custom',
|
|
|
|
|
|
endpoint: '',
|
|
|
|
|
|
bucket: '',
|
|
|
|
|
|
region: '',
|
|
|
|
|
|
accessKey: '',
|
|
|
|
|
|
secretKey: '',
|
|
|
|
|
|
pathStyle: false
|
|
|
|
|
|
});
|
|
|
|
|
|
let storageMsg = $state<{ text: string; ok: boolean } | null>(null);
|
|
|
|
|
|
let storageBusy = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadStorage() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
storage = await getStorageSettings();
|
|
|
|
|
|
sForm = {
|
|
|
|
|
|
backend: storage.backend ?? 'local',
|
|
|
|
|
|
preset: 'custom',
|
|
|
|
|
|
endpoint: storage.s3_endpoint_url ?? '',
|
|
|
|
|
|
bucket: storage.s3_bucket ?? '',
|
|
|
|
|
|
region: storage.s3_region ?? '',
|
|
|
|
|
|
accessKey: '',
|
|
|
|
|
|
secretKey: '',
|
|
|
|
|
|
pathStyle: storage.s3_force_path_style ?? false
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
storageMsg = { text: errorMessage(e), ok: false };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function applyPreset() {
|
|
|
|
|
|
const p = STORAGE_PRESETS[sForm.preset];
|
|
|
|
|
|
if (!p) return;
|
|
|
|
|
|
if (p.endpoint) sForm.endpoint = p.endpoint;
|
|
|
|
|
|
if (p.region) sForm.region = p.region;
|
|
|
|
|
|
sForm.pathStyle = p.pathStyle;
|
|
|
|
|
|
}
|
|
|
|
|
|
function storageBody() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
backend: sForm.backend,
|
|
|
|
|
|
s3_endpoint_url: sForm.endpoint.trim() || null,
|
|
|
|
|
|
s3_bucket: sForm.bucket.trim() || null,
|
|
|
|
|
|
s3_region: sForm.region.trim() || null,
|
|
|
|
|
|
s3_access_key: sForm.accessKey || null,
|
|
|
|
|
|
s3_secret_key: sForm.secretKey || null,
|
|
|
|
|
|
s3_force_path_style: sForm.pathStyle
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
async function doSaveStorage() {
|
|
|
|
|
|
storageBusy = true;
|
|
|
|
|
|
storageMsg = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await saveStorage(storageBody());
|
|
|
|
|
|
storageMsg = { text: t('admin.storage_saved', 'Storage settings saved.'), ok: true };
|
|
|
|
|
|
await loadStorage();
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
storageMsg = { text: errorMessage(e), ok: false };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
storageBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function doTestStorage() {
|
|
|
|
|
|
storageBusy = true;
|
|
|
|
|
|
storageMsg = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r: StorageTestResult = await testStorage(storageBody());
|
|
|
|
|
|
const ok = r.connected ?? r.success ?? false;
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
let text = t('admin.storage_test_success', 'Connection successful');
|
|
|
|
|
|
if (r.backend_type) text += ` (${r.backend_type})`;
|
|
|
|
|
|
if (r.available_bytes != null)
|
|
|
|
|
|
text += ` — ${formatBytes(r.available_bytes)} ${t('admin.available', 'available')}`;
|
|
|
|
|
|
storageMsg = { text, ok: true };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
storageMsg = {
|
|
|
|
|
|
text: `${t('admin.storage_test_failure', 'Connection failed')}: ${r.message ?? ''}`,
|
|
|
|
|
|
ok: false
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
storageMsg = { text: errorMessage(e), ok: false };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
storageBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Migration
|
|
|
|
|
|
let migration = $state<MigrationStatus | null>(null);
|
|
|
|
|
|
let migrationTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
function stopMigrationPoll() {
|
|
|
|
|
|
if (migrationTimer) {
|
|
|
|
|
|
clearInterval(migrationTimer);
|
|
|
|
|
|
migrationTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadMigration() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
migration = await getMigration();
|
|
|
|
|
|
if (migration.status === 'running') {
|
|
|
|
|
|
if (!migrationTimer) migrationTimer = setInterval(loadMigration, 2000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopMigrationPoll();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
stopMigrationPoll();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function doMigration(action: 'start' | 'pause' | 'resume' | 'complete') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await migrationAction(action);
|
|
|
|
|
|
await loadMigration();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Migration integrity verification (separate result panel).
|
|
|
|
|
|
let verifyResult = $state<MigrationVerifyResult | null>(null);
|
|
|
|
|
|
let verifyError = $state<string | null>(null);
|
|
|
|
|
|
let verifying = $state(false);
|
|
|
|
|
|
async function doVerify() {
|
|
|
|
|
|
verifying = true;
|
|
|
|
|
|
verifyResult = null;
|
|
|
|
|
|
verifyError = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
verifyResult = await verifyMigration(100);
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
verifyError = errorMessage(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
verifying = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const migrationPct = $derived(
|
|
|
|
|
|
migration && migration.total_blobs > 0
|
|
|
|
|
|
? Math.round((migration.migrated_blobs / migration.total_blobs) * 100)
|
|
|
|
|
|
: 0
|
|
|
|
|
|
);
|
|
|
|
|
|
/** Estimated minutes remaining, derived from throughput + average blob size. */
|
|
|
|
|
|
const migrationEtaMin = $derived.by(() => {
|
|
|
|
|
|
const m = migration;
|
|
|
|
|
|
if (!m || m.status !== 'running' || !m.throughput_bytes_per_sec) return null;
|
|
|
|
|
|
const remaining = m.total_blobs - m.migrated_blobs;
|
|
|
|
|
|
if (remaining <= 0 || m.migrated_blobs <= 0) return null;
|
|
|
|
|
|
const avgBlobSize = m.migrated_bytes / m.migrated_blobs;
|
|
|
|
|
|
const etaSecs = (remaining * avgBlobSize) / m.throughput_bytes_per_sec;
|
|
|
|
|
|
return Math.ceil(etaSecs / 60);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Plugin logs
|
|
|
|
|
|
let logsPlugin = $state<PluginInfo | null>(null);
|
|
|
|
|
|
let logs = $state<PluginLogEntry[]>([]);
|
|
|
|
|
|
let logsLevel = $state('');
|
|
|
|
|
|
let logsSearch = $state('');
|
|
|
|
|
|
let logsLoading = $state(false);
|
|
|
|
|
|
let logsPage = $state(0);
|
|
|
|
|
|
let logsTotal = $state(0);
|
|
|
|
|
|
let logsLive = $state(true);
|
|
|
|
|
|
let logStream: EventSource | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Best-effort message text across the persisted (`msg`) and legacy shapes. */
|
|
|
|
|
|
function logMsg(e: PluginLogEntry): string {
|
|
|
|
|
|
return e.msg ?? e.message ?? '';
|
|
|
|
|
|
}
|
|
|
|
|
|
/** Kind column: outcome entries surface their reason, others read "log". */
|
|
|
|
|
|
function logKind(e: PluginLogEntry): string {
|
|
|
|
|
|
return e.kind === 'outcome' ? (e.reason ?? 'outcome') : 'log';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopLogStream() {
|
|
|
|
|
|
if (logStream) {
|
|
|
|
|
|
logStream.close();
|
|
|
|
|
|
logStream = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Open the SSE live tail for the current plugin (no-op when Live is off). */
|
|
|
|
|
|
function startLogStream() {
|
|
|
|
|
|
stopLogStream();
|
|
|
|
|
|
if (!logsPlugin || !logsLive) return;
|
|
|
|
|
|
const es = new EventSource(
|
|
|
|
|
|
`/api/admin/plugins/${encodeURIComponent(logsPlugin.id)}/logs/stream`,
|
|
|
|
|
|
{ withCredentials: true }
|
|
|
|
|
|
);
|
|
|
|
|
|
es.onmessage = (ev) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
onLiveLogEntry(JSON.parse(ev.data) as PluginLogEntry);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* ignore malformed frames */
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
// Fell behind the broadcast buffer — resync from the server.
|
|
|
|
|
|
es.addEventListener('lagged', () => void loadLogs());
|
|
|
|
|
|
logStream = es;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Prepend a streamed entry, but only on the newest page and when it passes
|
|
|
|
|
|
* the active filter — so the live tail never fights pagination.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function onLiveLogEntry(entry: PluginLogEntry) {
|
|
|
|
|
|
if (logsPage !== 0) return;
|
|
|
|
|
|
if (logsLevel && (entry.level ?? '').toLowerCase() !== logsLevel.toLowerCase()) return;
|
|
|
|
|
|
if (logsSearch && !logMsg(entry).toLowerCase().includes(logsSearch.toLowerCase())) return;
|
|
|
|
|
|
logs = [entry, ...logs].slice(0, LOGS_PAGE_SIZE);
|
|
|
|
|
|
logsTotal += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleLive() {
|
|
|
|
|
|
if (logsLive) startLogStream();
|
|
|
|
|
|
else stopLogStream();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function logsPrev() {
|
|
|
|
|
|
if (logsPage > 0) {
|
|
|
|
|
|
logsPage--;
|
|
|
|
|
|
void loadLogs();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function logsNext() {
|
|
|
|
|
|
if ((logsPage + 1) * LOGS_PAGE_SIZE < logsTotal) {
|
|
|
|
|
|
logsPage++;
|
|
|
|
|
|
void loadLogs();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Plugin detail (metadata + retention) — opened alongside logs
|
|
|
|
|
|
let retention = $state<PluginRetention | null>(null);
|
|
|
|
|
|
let retentionDays = $state(0);
|
|
|
|
|
|
let retentionMb = $state(0);
|
|
|
|
|
|
let retentionMsg = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
async function openLogs(p: PluginInfo) {
|
|
|
|
|
|
logsPlugin = p;
|
|
|
|
|
|
retention = null;
|
|
|
|
|
|
retentionMsg = null;
|
|
|
|
|
|
logsPage = 0;
|
|
|
|
|
|
logsLevel = '';
|
|
|
|
|
|
logsSearch = '';
|
|
|
|
|
|
await Promise.all([loadLogs(), loadRetention(p.id)]);
|
|
|
|
|
|
startLogStream();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeLogs() {
|
|
|
|
|
|
stopLogStream();
|
|
|
|
|
|
logsPlugin = null;
|
|
|
|
|
|
logs = [];
|
|
|
|
|
|
logsTotal = 0;
|
|
|
|
|
|
logsPage = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Reset to the first page (filter changed) then reload. */
|
|
|
|
|
|
function reloadLogsFromStart() {
|
|
|
|
|
|
logsPage = 0;
|
|
|
|
|
|
void loadLogs();
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadRetention(id: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
retention = await getPluginRetention(id);
|
|
|
|
|
|
if (retention) {
|
|
|
|
|
|
retentionDays = retention.retention_days;
|
|
|
|
|
|
retentionMb = Math.round(retention.max_bytes / (1024 * 1024));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* retention is optional — leave unset on error */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function saveRetention() {
|
|
|
|
|
|
if (!logsPlugin) return;
|
|
|
|
|
|
retentionMsg = null;
|
|
|
|
|
|
if (
|
|
|
|
|
|
!Number.isFinite(retentionDays) ||
|
|
|
|
|
|
retentionDays < 0 ||
|
|
|
|
|
|
!Number.isFinite(retentionMb) ||
|
|
|
|
|
|
retentionMb < 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
retentionMsg = t('admin.plugins_retention_invalid', 'Enter non-negative numbers.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await savePluginRetention(logsPlugin.id, {
|
|
|
|
|
|
retention_days: Math.round(retentionDays),
|
|
|
|
|
|
max_bytes: Math.round(retentionMb) * 1024 * 1024
|
|
|
|
|
|
});
|
|
|
|
|
|
retentionMsg = t('admin.plugins_retention_saved', 'Retention saved.');
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
retentionMsg = errorMessage(e);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function purgeLogs() {
|
|
|
|
|
|
if (!logsPlugin) return;
|
|
|
|
|
|
if (
|
|
|
|
|
|
!(await showConfirm(t('admin.plugins_logs_confirm_clear', 'Clear all logs for this plugin?')))
|
|
|
|
|
|
)
|
|
|
|
|
|
return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await clearPluginLogs(logsPlugin.id);
|
|
|
|
|
|
logsPage = 0;
|
|
|
|
|
|
await loadLogs();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Plugin install (.zip upload)
|
|
|
|
|
|
let installing = $state(false);
|
|
|
|
|
|
let installMsg = $state<{ ok: boolean; text: string } | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
async function onInstallPlugin(e: Event) {
|
|
|
|
|
|
const input = e.currentTarget as HTMLInputElement;
|
|
|
|
|
|
const file = input.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
installing = true;
|
|
|
|
|
|
installMsg = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const info = await installPlugin(file);
|
|
|
|
|
|
installMsg = {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
text: t('admin.plugins_installed', { name: info.name }, `Installed ${info.name}.`)
|
|
|
|
|
|
};
|
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
|
} catch (err) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
installMsg = { ok: false, text: errorMessage(err) };
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
installing = false;
|
|
|
|
|
|
input.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadLogs() {
|
|
|
|
|
|
if (!logsPlugin) return;
|
|
|
|
|
|
logsLoading = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const page = await getPluginLogs(logsPlugin.id, {
|
|
|
|
|
|
level: logsLevel,
|
|
|
|
|
|
search: logsSearch,
|
|
|
|
|
|
limit: LOGS_PAGE_SIZE,
|
|
|
|
|
|
offset: logsPage * LOGS_PAGE_SIZE
|
|
|
|
|
|
});
|
|
|
|
|
|
logs = page.entries;
|
|
|
|
|
|
logsTotal = page.total;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
logsLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
|
|
// Users
|
|
|
|
|
|
let users = $state<User[]>([]);
|
|
|
|
|
|
let total = $state(0);
|
|
|
|
|
|
let pageIndex = $state(0);
|
|
|
|
|
|
let usersError = $state<string | null>(null);
|
|
|
|
|
|
let createOpen = $state(false);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
let createError = $state<string | null>(null);
|
|
|
|
|
|
let creating = $state(false);
|
|
|
|
|
|
let newUser = $state({
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
quotaValue: 5,
|
|
|
|
|
|
quotaUnit: (1024 ** 3) as number
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Quota edit modal
|
|
|
|
|
|
let quotaModal = $state<{
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
username: string;
|
|
|
|
|
|
value: number;
|
|
|
|
|
|
unit: number;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Reset-password modal
|
|
|
|
|
|
let resetModal = $state<{ userId: string; username: string } | null>(null);
|
|
|
|
|
|
let resetPassword = $state('');
|
|
|
|
|
|
let resetError = $state<string | null>(null);
|
|
|
|
|
|
let resetting = $state(false);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
|
|
// Plugins
|
|
|
|
|
|
let plugins = $state<PluginInfo[]>([]);
|
|
|
|
|
|
let pluginsAvailable = $state(true);
|
|
|
|
|
|
let pluginsError = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadUsers() {
|
|
|
|
|
|
usersError = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
|
|
|
|
|
|
users = page.users;
|
|
|
|
|
|
total = page.total;
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
usersError = errorMessage(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadPlugins() {
|
|
|
|
|
|
pluginsError = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await listPlugins();
|
|
|
|
|
|
pluginsAvailable = res.available;
|
|
|
|
|
|
plugins = res.plugins;
|
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
pluginsError = errorMessage(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reportError(e: unknown) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
async function copyText(text: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
|
ui.notify(t('common.copied', 'Copied to clipboard'), 'success');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ui.notify(t('common.copy_failed', 'Copy failed'), 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** True when a settings field is locked by an OXICLOUD_* env var. */
|
|
|
|
|
|
function isEnvLocked(overrides: string[] | undefined, field: string): boolean {
|
|
|
|
|
|
return Array.isArray(overrides) && overrides.includes(field);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** True for the signed-in admin's own row — guards self-destructive actions. */
|
|
|
|
|
|
function isSelf(u: User): boolean {
|
|
|
|
|
|
return u.id === currentAdminId;
|
|
|
|
|
|
}
|
|
|
|
|
|
/** OIDC/SSO-provisioned account (no local password to reset). */
|
|
|
|
|
|
function isOidcUser(u: User): boolean {
|
|
|
|
|
|
return !!u.auth_provider && u.auth_provider !== 'local';
|
|
|
|
|
|
}
|
|
|
|
|
|
/** Used-quota percentage (0 when unlimited) for the per-user progress bar. */
|
|
|
|
|
|
function quotaPct(u: User): number {
|
|
|
|
|
|
return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
async function toggleRole(u: User) {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (isSelf(u)) return;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
const role = u.role === 'admin' ? 'user' : 'admin';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
try {
|
|
|
|
|
|
await setUserRole(u.id, role);
|
|
|
|
|
|
await loadUsers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleActive(u: User) {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (isSelf(u) && u.active) return;
|
|
|
|
|
|
const msg = u.active
|
|
|
|
|
|
? t('admin.confirm_deactivate', 'Deactivate this user?')
|
|
|
|
|
|
: t('admin.confirm_activate', 'Activate this user?');
|
|
|
|
|
|
if (!(await showConfirm(msg))) return;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
try {
|
|
|
|
|
|
await setUserActive(u.id, !u.active);
|
|
|
|
|
|
await loadUsers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
function openQuota(u: User) {
|
|
|
|
|
|
quotaModal = {
|
|
|
|
|
|
userId: u.id,
|
|
|
|
|
|
username: u.username || u.email,
|
|
|
|
|
|
value:
|
|
|
|
|
|
u.storage_quota_bytes > 0 ? Math.round((u.storage_quota_bytes / 1024 ** 3) * 10) / 10 : 0,
|
|
|
|
|
|
unit: 1024 ** 3
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
async function saveQuota() {
|
|
|
|
|
|
if (!quotaModal) return;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
try {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
await setUserQuota(quotaModal.userId, Math.round(quotaModal.value * quotaModal.unit));
|
|
|
|
|
|
quotaModal = null;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
await loadUsers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
function openReset(u: User) {
|
|
|
|
|
|
resetModal = { userId: u.id, username: u.username || u.email };
|
|
|
|
|
|
resetPassword = '';
|
|
|
|
|
|
resetError = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
async function submitReset(e: SubmitEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (!resetModal) return;
|
|
|
|
|
|
if (resetPassword.length < 8) {
|
|
|
|
|
|
resetError = t('admin.error_password_short', 'Password must be at least 8 characters.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
resetting = true;
|
|
|
|
|
|
resetError = null;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
try {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
await resetUserPassword(resetModal.userId, resetPassword);
|
|
|
|
|
|
resetModal = null;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
ui.notify(t('admin.password_reset', 'Password reset'), 'success');
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} catch (err) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
resetError = errorMessage(err);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
resetting = false;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeUser(u: User) {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (isSelf(u)) return;
|
|
|
|
|
|
if (
|
|
|
|
|
|
!(await showConfirm(
|
|
|
|
|
|
t('admin.confirm_delete_user', { name: u.username || u.email }, 'Delete user {{name}}?')
|
|
|
|
|
|
))
|
|
|
|
|
|
)
|
2026-06-17 17:06:30 -06:00
|
|
|
|
return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteUser(u.id);
|
|
|
|
|
|
await loadUsers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitCreate(e: SubmitEvent) {
|
|
|
|
|
|
e.preventDefault();
|
2026-06-17 22:07:18 -06:00
|
|
|
|
const username = newUser.username.trim();
|
|
|
|
|
|
const email = newUser.email.trim();
|
|
|
|
|
|
if (username.length < 3) {
|
|
|
|
|
|
createError = t('admin.error_username_short', 'Username must be at least 3 characters.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (newUser.password.length < 8) {
|
|
|
|
|
|
createError = t('admin.error_password_short', 'Password must be at least 8 characters.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
creating = true;
|
|
|
|
|
|
createError = null;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
try {
|
|
|
|
|
|
await createUser({
|
2026-06-17 22:07:18 -06:00
|
|
|
|
username,
|
|
|
|
|
|
// Email is optional — the backend auto-generates one when blank.
|
|
|
|
|
|
email: email || null,
|
2026-06-17 17:06:30 -06:00
|
|
|
|
password: newUser.password,
|
|
|
|
|
|
role: newUser.role,
|
2026-06-17 22:07:18 -06:00
|
|
|
|
quota_bytes: Math.round(newUser.quotaValue * newUser.quotaUnit)
|
2026-06-17 17:06:30 -06:00
|
|
|
|
});
|
|
|
|
|
|
createOpen = false;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
newUser = {
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
quotaValue: 5,
|
|
|
|
|
|
quotaUnit: 1024 ** 3
|
|
|
|
|
|
};
|
2026-06-17 17:06:30 -06:00
|
|
|
|
await loadUsers();
|
|
|
|
|
|
} catch (err) {
|
2026-06-17 22:30:56 -06:00
|
|
|
|
createError = errorMessage(err);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} finally {
|
|
|
|
|
|
creating = false;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function togglePlugin(p: PluginInfo) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setPluginEnabled(p.id, !p.enabled);
|
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removePlugin(p: PluginInfo) {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (
|
|
|
|
|
|
!(await showConfirm(
|
|
|
|
|
|
t('admin.confirm_delete_plugin', { name: p.name }, 'Delete plugin {{name}}?')
|
|
|
|
|
|
))
|
|
|
|
|
|
)
|
2026-06-17 17:06:30 -06:00
|
|
|
|
return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deletePlugin(p.id);
|
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportError(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function changePage(delta: number) {
|
|
|
|
|
|
const next = pageIndex + delta;
|
|
|
|
|
|
if (next < 0 || next * PAGE_SIZE >= total) return;
|
|
|
|
|
|
pageIndex = next;
|
|
|
|
|
|
void loadUsers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
// Lazy-load each tab's data on first visit.
|
|
|
|
|
|
let loaded = $state<Record<Tab, boolean>>({
|
|
|
|
|
|
dashboard: false,
|
|
|
|
|
|
users: false,
|
|
|
|
|
|
plugins: false,
|
|
|
|
|
|
oidc: false,
|
|
|
|
|
|
storage: false,
|
|
|
|
|
|
smtp: false
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
|
if (loaded[tab]) return;
|
|
|
|
|
|
loaded[tab] = true;
|
|
|
|
|
|
if (tab === 'dashboard') void loadDashboard();
|
|
|
|
|
|
else if (tab === 'users') void loadUsers();
|
|
|
|
|
|
else if (tab === 'plugins') void loadPlugins();
|
|
|
|
|
|
else if (tab === 'oidc') void loadOidc();
|
|
|
|
|
|
else if (tab === 'storage') {
|
|
|
|
|
|
void loadStorage();
|
|
|
|
|
|
void loadMigration();
|
|
|
|
|
|
} else if (tab === 'smtp') void loadSmtp();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Stop polling when leaving the storage tab / unmounting.
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
|
if (tab !== 'storage') stopMigrationPoll();
|
|
|
|
|
|
return () => stopMigrationPoll();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Tear down the live log stream when leaving plugins / unmounting.
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
|
if (tab !== 'plugins') stopLogStream();
|
|
|
|
|
|
return () => stopLogStream();
|
2026-06-17 17:06:30 -06:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>{t('admin.title', 'Admin')} · OxiCloud</title></svelte:head>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{#snippet envBadge(on: boolean)}
|
|
|
|
|
|
{#if on}
|
|
|
|
|
|
<span class="badge badge--env" title={t('admin.env_locked', 'Set by an environment variable')}
|
|
|
|
|
|
>ENV</span
|
|
|
|
|
|
>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<main class="admin">
|
|
|
|
|
|
<h1>{t('admin.title', 'Admin')}</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="tabs" role="tablist">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button role="tab" aria-selected={tab === 'dashboard'} onclick={() => (tab = 'dashboard')}>
|
|
|
|
|
|
<Icon name="chart-pie" />
|
|
|
|
|
|
{t('admin.dashboard', 'Dashboard')}
|
|
|
|
|
|
</button>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="users" />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{t('admin.users', 'Users')}
|
|
|
|
|
|
</button>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button role="tab" aria-selected={tab === 'oidc'} onclick={() => (tab = 'oidc')}>
|
|
|
|
|
|
<Icon name="key" />
|
|
|
|
|
|
{t('admin.oidc', 'OIDC / SSO')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button role="tab" aria-selected={tab === 'storage'} onclick={() => (tab = 'storage')}>
|
|
|
|
|
|
<Icon name="database" />
|
|
|
|
|
|
{t('admin.storage_tab', 'Storage')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button role="tab" aria-selected={tab === 'smtp'} onclick={() => (tab = 'smtp')}>
|
|
|
|
|
|
<Icon name="envelope" />
|
|
|
|
|
|
{t('admin.smtp', 'Email (SMTP)')}
|
|
|
|
|
|
</button>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="layer-group" />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{t('admin.plugins', 'Plugins')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{#if tab === 'dashboard'}
|
|
|
|
|
|
{#if dashboardError}
|
|
|
|
|
|
<p class="status status--error">{dashboardError}</p>
|
|
|
|
|
|
{:else if !dashboard}
|
|
|
|
|
|
<p class="status">{t('common.loading', 'Loading…')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<div class="ds-grid">
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-num">{dashboard.total_users}</span>{t('admin.total_users', 'Total users')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-num">{dashboard.active_users}</span>{t('admin.active_users', 'Active')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-num">{dashboard.admin_users}</span>{t('admin.admin_users', 'Admins')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ds-grid">
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
|
|
|
|
|
|
{dashboard.auth_enabled
|
|
|
|
|
|
? t('admin.enabled', 'Enabled')
|
|
|
|
|
|
: t('admin.disabled', 'Disabled')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{t('admin.auth', 'Authentication')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-flag" class:ds-flag--on={dashboard.oidc_configured}>
|
|
|
|
|
|
{dashboard.oidc_configured ? t('admin.active', 'Active') : t('admin.off', 'Off')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{t('admin.oidc', 'OIDC / SSO')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ds-card">
|
|
|
|
|
|
<span class="ds-flag" class:ds-flag--on={dashboard.quotas_enabled}>
|
|
|
|
|
|
{dashboard.quotas_enabled
|
|
|
|
|
|
? t('admin.enabled', 'Enabled')
|
|
|
|
|
|
: t('admin.disabled', 'Disabled')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{t('admin.quotas', 'Quotas')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if dashboard.users_over_quota > 0}
|
|
|
|
|
|
<div class="card warn-card warn-card--danger">
|
|
|
|
|
|
<Icon name="exclamation-circle" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong class="ds-num">{dashboard.users_over_quota}</strong>
|
|
|
|
|
|
{t('admin.over_quota', { n: dashboard.users_over_quota }, '{{n}} users over quota')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if dashboard.users_over_80_percent > 0}
|
|
|
|
|
|
<div class="card warn-card warn-card--warn">
|
|
|
|
|
|
<Icon name="exclamation-triangle" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong class="ds-num">{dashboard.users_over_80_percent}</strong>
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'admin.over_80',
|
|
|
|
|
|
{ n: dashboard.users_over_80_percent },
|
|
|
|
|
|
'{{n}} users over 80% quota'
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.storage', 'Storage')}</h2>
|
|
|
|
|
|
<div class="ds-bar">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="ds-fill"
|
|
|
|
|
|
class:ds-fill--warn={dashboard.storage_usage_percent > 70}
|
|
|
|
|
|
class:ds-fill--danger={dashboard.storage_usage_percent > 90}
|
|
|
|
|
|
style:width="{Math.min(dashboard.storage_usage_percent, 100)}%"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="muted">
|
|
|
|
|
|
{formatBytes(dashboard.total_used_bytes)} / {formatBytes(dashboard.total_quota_bytes)}
|
|
|
|
|
|
({dashboard.storage_usage_percent.toFixed(1)}%)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if dashboard.registration_enabled !== undefined}
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.registration', 'Registration')}</h2>
|
|
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={dashboard.registration_enabled}
|
|
|
|
|
|
onchange={(e) => toggleRegistration(e.currentTarget.checked)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>{t('admin.allow_registration', 'Allow public user registration')}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{#if !dashboard.registration_enabled}
|
|
|
|
|
|
<p class="alert alert--warn registration-warning">
|
|
|
|
|
|
<Icon name="exclamation-triangle" />
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'admin.registration_disabled_warning',
|
|
|
|
|
|
'Public registration is disabled. Only admins can create new accounts.'
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{:else if tab === 'oidc'}
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.oidc', 'OIDC / SSO')}</h2>
|
|
|
|
|
|
{#if !oidc}
|
|
|
|
|
|
<p class="status">{t('common.loading', 'Loading…')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveOidc())}>
|
|
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input type="checkbox" bind:checked={oidc.enabled} />
|
|
|
|
|
|
<span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.oidc_issuer', 'Issuer URL')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(oidc.env_overrides, 'issuer_url')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={oidc.issuer_url}
|
|
|
|
|
|
placeholder="https://idp.example.com"
|
|
|
|
|
|
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" onclick={runOidcTest}>
|
|
|
|
|
|
<Icon name="search" />
|
|
|
|
|
|
{t('admin.oidc_discover', 'Test / discover')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{#if oidcTest}
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="discovery-result {oidcTest.success
|
|
|
|
|
|
? 'discovery-result--ok'
|
|
|
|
|
|
: 'discovery-result--fail'}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong>
|
|
|
|
|
|
<Icon name={oidcTest.success ? 'check-circle' : 'times-circle'} />
|
|
|
|
|
|
{oidcTest.message}
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
{#if oidcTest.success}
|
|
|
|
|
|
<dl class="kv">
|
|
|
|
|
|
<dt>{t('admin.oidc_issuer', 'Issuer URL')}</dt>
|
|
|
|
|
|
<dd>{oidcTest.issuer || '—'}</dd>
|
|
|
|
|
|
<dt>{t('admin.oidc_auth_endpoint', 'Auth endpoint')}</dt>
|
|
|
|
|
|
<dd>{oidcTest.authorization_endpoint || '—'}</dd>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.oidc_client_id', 'Client ID')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(oidc.env_overrides, 'client_id')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={oidc.client_id}
|
|
|
|
|
|
disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.oidc_client_secret', 'Client secret')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(oidc.env_overrides, 'client_secret')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
bind:value={oidc.client_secret}
|
|
|
|
|
|
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
|
|
|
|
|
|
placeholder={oidc.client_secret_set
|
|
|
|
|
|
? t('admin.unchanged', 'Leave blank to keep current')
|
|
|
|
|
|
: ''}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{#if oidc.client_secret_set}
|
|
|
|
|
|
<span class="secret-hint">
|
|
|
|
|
|
<Icon name="check-circle" />
|
|
|
|
|
|
{t('admin.oidc_secret_set', 'A client secret is already configured.')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{/if}</label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.oidc_scopes', 'Scopes')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(oidc.env_overrides, 'scopes')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={oidc.scopes}
|
|
|
|
|
|
placeholder="openid profile email"
|
|
|
|
|
|
disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.oidc_provider_name', 'Provider name')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(oidc.env_overrides, 'provider_name')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={oidc.provider_name}
|
|
|
|
|
|
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.oidc_admin_groups', 'Admin groups')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(oidc.env_overrides, 'admin_groups')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={oidc.admin_groups}
|
|
|
|
|
|
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input type="checkbox" bind:checked={oidc.auto_provision} />
|
|
|
|
|
|
<span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input type="checkbox" bind:checked={oidc.disable_password_login} />
|
|
|
|
|
|
<span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{#if oidc.callback_url}
|
|
|
|
|
|
<p class="muted callback-row">
|
|
|
|
|
|
{t('admin.oidc_callback', 'Callback URL')}: <code>{oidc.callback_url}</code>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-sm btn-secondary"
|
|
|
|
|
|
onclick={() => copyText(oidc?.callback_url ?? '')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="copy" />
|
|
|
|
|
|
{t('common.copy', 'Copy')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
|
|
|
|
|
|
{oidcMsg.text}
|
|
|
|
|
|
</p>{/if}
|
|
|
|
|
|
<button class="btn btn-primary" type="submit" disabled={oidcSaving}>
|
|
|
|
|
|
{t('common.save', 'Save')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else if tab === 'storage'}
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.storage_tab', 'Storage')}</h2>
|
|
|
|
|
|
{#if !storage}
|
|
|
|
|
|
<p class="status">{t('common.loading', 'Loading…')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.storage_backend', 'Backend')}</span>
|
|
|
|
|
|
<select bind:value={sForm.backend}>
|
|
|
|
|
|
<option value="local">local</option>
|
|
|
|
|
|
<option value="s3">S3</option>
|
|
|
|
|
|
</select></label
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if sForm.backend === 's3'}
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.storage_preset', 'Preset')}</span>
|
|
|
|
|
|
<select bind:value={sForm.preset} onchange={applyPreset}>
|
|
|
|
|
|
{#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each}
|
|
|
|
|
|
</select></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.storage_endpoint', 'Endpoint URL')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(storage.env_overrides, 's3_endpoint_url')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={sForm.endpoint}
|
|
|
|
|
|
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.storage_bucket', 'Bucket')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(storage.env_overrides, 's3_bucket')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={sForm.bucket}
|
|
|
|
|
|
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.storage_region', 'Region')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(storage.env_overrides, 's3_region')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={sForm.region}
|
|
|
|
|
|
disabled={isEnvLocked(storage.env_overrides, 's3_region')}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.storage_access_key', 'Access key')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(storage.env_overrides, 's3_access_key')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
bind:value={sForm.accessKey}
|
|
|
|
|
|
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
|
|
|
|
|
|
placeholder={storage.s3_access_key_set
|
|
|
|
|
|
? t('admin.unchanged', 'Leave blank to keep current')
|
|
|
|
|
|
: ''}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.storage_secret_key', 'Secret key')}{@render envBadge(
|
|
|
|
|
|
isEnvLocked(storage.env_overrides, 's3_secret_key')
|
|
|
|
|
|
)}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
bind:value={sForm.secretKey}
|
|
|
|
|
|
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
|
|
|
|
|
|
placeholder={storage.s3_secret_key_set
|
|
|
|
|
|
? t('admin.unchanged', 'Leave blank to keep current')
|
|
|
|
|
|
: ''}
|
|
|
|
|
|
/></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input type="checkbox" bind:checked={sForm.pathStyle} />
|
|
|
|
|
|
<span>{t('admin.storage_path_style', 'Force path-style URLs')}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if storageMsg}<p class={storageMsg.ok ? 'status--ok' : 'status--error'}>
|
|
|
|
|
|
{storageMsg.text}
|
|
|
|
|
|
</p>{/if}
|
|
|
|
|
|
<div class="smtp-test">
|
|
|
|
|
|
<button class="btn btn-primary" type="submit" disabled={storageBusy}
|
|
|
|
|
|
>{t('common.save', 'Save')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if sForm.backend === 's3'}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn btn-secondary"
|
|
|
|
|
|
disabled={storageBusy}
|
|
|
|
|
|
onclick={doTestStorage}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('admin.storage_test', 'Test connection')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<dl class="kv">
|
|
|
|
|
|
<dt>{t('admin.storage_current', 'Current backend')}</dt>
|
|
|
|
|
|
<dd>{storage.current_backend ?? '—'}</dd>
|
|
|
|
|
|
<dt>{t('admin.storage_blobs', 'Blobs')}</dt>
|
|
|
|
|
|
<dd>{storage.total_blobs ?? '—'}</dd>
|
|
|
|
|
|
<dt>{t('admin.storage_size', 'Stored')}</dt>
|
|
|
|
|
|
<dd>
|
|
|
|
|
|
{storage.total_bytes_stored != null ? formatBytes(storage.total_bytes_stored) : '—'}
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
<dt>{t('admin.storage_dedup', 'Dedup ratio')}</dt>
|
|
|
|
|
|
<dd>{storage.dedup_ratio != null ? `${storage.dedup_ratio.toFixed(2)}x` : '—'}</dd>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.migration', 'Storage migration')}</h2>
|
|
|
|
|
|
{#if !migration}
|
|
|
|
|
|
<p class="status">{t('common.loading', 'Loading…')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<p class="muted">{t('admin.status', 'Status')}: <strong>{migration.status}</strong></p>
|
|
|
|
|
|
{#if migration.total_blobs > 0}
|
|
|
|
|
|
<div class="ds-bar">
|
|
|
|
|
|
<div class="ds-fill" style:width="{migrationPct}%"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="muted">
|
|
|
|
|
|
{migration.migrated_blobs} / {migration.total_blobs} ({migrationPct}%) ·
|
|
|
|
|
|
{formatBytes(migration.migrated_bytes)}
|
|
|
|
|
|
{#if migration.throughput_bytes_per_sec && migration.status === 'running'}
|
|
|
|
|
|
· {formatBytes(Math.round(migration.throughput_bytes_per_sec))}/s
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if migrationEtaMin != null}
|
|
|
|
|
|
· {t('admin.mig_eta', { min: migrationEtaMin }, `~${migrationEtaMin} min remaining`)}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if migration.failed_blobs && migration.failed_blobs.length > 0}
|
|
|
|
|
|
<details class="mig-failed">
|
|
|
|
|
|
<summary>
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'admin.mig_failed',
|
|
|
|
|
|
{ n: migration.failed_blobs.length },
|
|
|
|
|
|
`${migration.failed_blobs.length} failed blobs`
|
|
|
|
|
|
)}
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
<pre class="mig-failed__list">{migration.failed_blobs.join('\n')}</pre>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<div class="smtp-test">
|
|
|
|
|
|
<!-- Start: only when no migration is active (running/paused) or completed. -->
|
|
|
|
|
|
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
|
|
|
|
|
|
<button class="btn btn-primary" onclick={() => doMigration('start')}
|
|
|
|
|
|
>{t('admin.mig_start', 'Start')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if migration.status === 'running'}
|
|
|
|
|
|
<button class="btn btn-secondary" onclick={() => doMigration('pause')}
|
|
|
|
|
|
>{t('admin.mig_pause', 'Pause')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if migration.status === 'paused'}
|
|
|
|
|
|
<button class="btn btn-primary" onclick={() => doMigration('resume')}
|
|
|
|
|
|
>{t('admin.mig_resume', 'Resume')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<!-- Verify + Finalize: only once the copy phase has completed. -->
|
|
|
|
|
|
{#if migration.status === 'completed'}
|
|
|
|
|
|
<button class="btn btn-secondary" disabled={verifying} onclick={doVerify}>
|
|
|
|
|
|
<Icon name="check-double" />
|
|
|
|
|
|
{verifying
|
|
|
|
|
|
? t('admin.mig_verifying', 'Verifying…')
|
|
|
|
|
|
: t('admin.mig_verify', 'Verify integrity')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="btn btn-secondary" onclick={() => doMigration('complete')}
|
|
|
|
|
|
>{t('admin.mig_complete', 'Finalize')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if verifyError}
|
|
|
|
|
|
<div class="discovery-result discovery-result--fail">
|
|
|
|
|
|
<strong><Icon name="times-circle" /> {verifyError}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else if verifyResult}
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="discovery-result {verifyResult.passed
|
|
|
|
|
|
? 'discovery-result--ok'
|
|
|
|
|
|
: 'discovery-result--fail'}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong>
|
|
|
|
|
|
<Icon name={verifyResult.passed ? 'check-circle' : 'times-circle'} />
|
|
|
|
|
|
{verifyResult.passed
|
|
|
|
|
|
? t('admin.mig_verify_passed', 'Verification passed')
|
|
|
|
|
|
: t('admin.mig_verify_failed', 'Verification failed')}
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
{#if verifyResult.passed}
|
|
|
|
|
|
<p class="muted">
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'admin.mig_verify_summary',
|
|
|
|
|
|
{ checked: verifyResult.sample_checked, total: verifyResult.pg_blob_count },
|
|
|
|
|
|
'{{checked}} blobs checked, {{total}} total in database'
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<p class="muted">
|
|
|
|
|
|
{[
|
|
|
|
|
|
verifyResult.missing_in_target.length
|
|
|
|
|
|
? t(
|
|
|
|
|
|
'admin.mig_verify_missing',
|
|
|
|
|
|
{ n: verifyResult.missing_in_target.length },
|
|
|
|
|
|
'{{n}} missing'
|
|
|
|
|
|
)
|
|
|
|
|
|
: '',
|
|
|
|
|
|
verifyResult.size_mismatches.length
|
|
|
|
|
|
? t(
|
|
|
|
|
|
'admin.mig_verify_mismatch',
|
|
|
|
|
|
{ n: verifyResult.size_mismatches.length },
|
|
|
|
|
|
'{{n}} size mismatches'
|
|
|
|
|
|
)
|
|
|
|
|
|
: ''
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(', ')}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else if tab === 'smtp'}
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.smtp_status', 'SMTP status')}</h2>
|
|
|
|
|
|
{#if !smtp}
|
|
|
|
|
|
<p class="status">{t('common.loading', 'Loading…')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<dl class="kv">
|
|
|
|
|
|
<dt>{t('admin.smtp_enabled', 'Enabled')}</dt>
|
|
|
|
|
|
<dd>{smtp.enabled ? t('common.yes', 'Yes') : t('common.no', 'No')}</dd>
|
|
|
|
|
|
<dt>{t('admin.smtp_host', 'Host')}</dt>
|
|
|
|
|
|
<dd>{smtp.host || '—'}</dd>
|
|
|
|
|
|
<dt>{t('admin.smtp_port', 'Port')}</dt>
|
|
|
|
|
|
<dd>{smtp.port || '—'}</dd>
|
|
|
|
|
|
<dt>TLS</dt>
|
|
|
|
|
|
<dd>{smtp.tls || '—'}</dd>
|
|
|
|
|
|
<dt>{t('admin.smtp_from', 'From')}</dt>
|
|
|
|
|
|
<dd>{smtp.from || '—'}</dd>
|
|
|
|
|
|
<dt>{t('admin.smtp_user_state', 'Auth')}</dt>
|
|
|
|
|
|
<dd>{smtp.user_state || '—'}</dd>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>{t('admin.smtp_test', 'Send test email')}</h2>
|
|
|
|
|
|
<div class="smtp-test">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
bind:value={smtpTo}
|
|
|
|
|
|
placeholder={t('admin.smtp_to', 'recipient@example.com')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button class="btn btn-primary" disabled={smtpSending} onclick={runSmtpTest}>
|
|
|
|
|
|
<Icon name="paper-plane" />
|
|
|
|
|
|
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{#if smtpResult}
|
|
|
|
|
|
{#if smtpResult.success}
|
|
|
|
|
|
<p class="status--ok">
|
|
|
|
|
|
<strong>{t('admin.smtp_sent', 'Test email sent.')}</strong><br />
|
|
|
|
|
|
{t('admin.smtp_server_code', 'Server replied')}:
|
|
|
|
|
|
<code>{smtpResult.code ?? ''} {smtpResult.message ?? ''}</code>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<p class="status--error">
|
|
|
|
|
|
<strong>{t('admin.smtp_fail', 'Send failed.')}</strong><br />
|
|
|
|
|
|
<code
|
|
|
|
|
|
>{smtpResult.error || smtpResult.message || t('common.error', 'unknown error')}</code
|
|
|
|
|
|
>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else if tab === 'users'}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<div class="bar">
|
|
|
|
|
|
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="user-plus" />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{t('admin.create_user', 'Create user')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{#if usersError}
|
|
|
|
|
|
<p class="status status--error">{usersError}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<table class="table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>{t('admin.user', 'User')}</th>
|
|
|
|
|
|
<th>{t('admin.role', 'Role')}</th>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<th>{t('admin.auth', 'Auth')}</th>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<th>{t('admin.status', 'Status')}</th>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<th>{t('admin.quota', 'Storage usage')}</th>
|
|
|
|
|
|
<th>{t('admin.last_login', 'Last login')}</th>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<th></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{#each users as u (u.id)}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{@const pct = quotaPct(u)}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<tr>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="user-cell">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<strong>
|
|
|
|
|
|
{u.username || u.email}
|
|
|
|
|
|
{#if isSelf(u)}
|
|
|
|
|
|
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</strong>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<span class="muted">{u.email}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<span class="badge badge--{u.role === 'admin' ? 'admin' : 'user'}">
|
|
|
|
|
|
{#if u.role === 'admin'}<Icon name="shield-alt" />{/if}
|
|
|
|
|
|
{u.role}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{#if isOidcUser(u)}
|
|
|
|
|
|
<span class="badge badge--oidc" title={u.auth_provider}>
|
|
|
|
|
|
<Icon name="key" />
|
|
|
|
|
|
{u.auth_provider}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<span class="badge badge--local">{t('admin.local', 'local')}</span>
|
|
|
|
|
|
{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</td>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<td>
|
|
|
|
|
|
<span class="badge badge--{u.active ? 'active' : 'inactive'}">
|
|
|
|
|
|
{u.active ? t('admin.active', 'Active') : t('admin.inactive', 'Inactive')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="quota-cell">
|
|
|
|
|
|
<div class="quota-bar">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="quota-fill"
|
|
|
|
|
|
class:quota-fill--warn={pct > 70}
|
|
|
|
|
|
class:quota-fill--danger={pct > 90}
|
|
|
|
|
|
style:width="{Math.min(pct, 100)}%"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="muted">
|
|
|
|
|
|
{formatBytes(u.storage_used_bytes)} / {u.storage_quota_bytes > 0
|
|
|
|
|
|
? formatBytes(u.storage_quota_bytes)
|
|
|
|
|
|
: '∞'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="muted">{timeAgo(u.last_login_at)}</td>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<td class="actions">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn"
|
|
|
|
|
|
title={t('admin.edit_quota_title', 'Edit quota')}
|
|
|
|
|
|
aria-label={t('admin.edit_quota_title', 'Edit quota')}
|
|
|
|
|
|
onclick={() => openQuota(u)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="box" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{#if !isOidcUser(u)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn"
|
|
|
|
|
|
title={t('admin.reset_password_title', 'Reset password')}
|
|
|
|
|
|
aria-label={t('admin.reset_password_title', 'Reset password')}
|
|
|
|
|
|
onclick={() => openReset(u)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="key" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn"
|
|
|
|
|
|
title={t('admin.toggle_role_title', 'Toggle admin role')}
|
|
|
|
|
|
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
|
|
|
|
|
|
disabled={isSelf(u)}
|
|
|
|
|
|
onclick={() => toggleRole(u)}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name={u.role === 'admin' ? 'user' : 'crown'} />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</button>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
|
|
|
|
|
|
title={u.active
|
|
|
|
|
|
? t('admin.deactivate_title', 'Deactivate')
|
|
|
|
|
|
: t('admin.activate_title', 'Activate')}
|
|
|
|
|
|
aria-label={u.active
|
|
|
|
|
|
? t('admin.deactivate_title', 'Deactivate')
|
|
|
|
|
|
: t('admin.activate_title', 'Activate')}
|
|
|
|
|
|
disabled={isSelf(u) && u.active}
|
|
|
|
|
|
onclick={() => toggleActive(u)}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name={u.active ? 'ban' : 'check'} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn icon-btn--danger"
|
|
|
|
|
|
title={t('admin.delete_title', 'Delete user')}
|
|
|
|
|
|
aria-label={t('admin.delete_title', 'Delete user')}
|
|
|
|
|
|
disabled={isSelf(u)}
|
|
|
|
|
|
onclick={() => removeUser(u)}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="trash-alt" />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
<div class="pager">
|
|
|
|
|
|
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
|
|
|
|
|
|
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn"
|
|
|
|
|
|
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
|
|
|
|
|
|
onclick={() => changePage(1)}>›</button
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{:else if !pluginsAvailable}
|
|
|
|
|
|
<p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p>
|
|
|
|
|
|
{:else if pluginsError}
|
|
|
|
|
|
<p class="status status--error">{pluginsError}</p>
|
|
|
|
|
|
{:else}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<div class="install-bar">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{t('admin.plugins_install', 'Install plugin')}</strong>
|
|
|
|
|
|
<span class="muted"
|
|
|
|
|
|
>{t('admin.plugins_install_hint', 'Upload a plugin bundle (.zip).')}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label class="btn btn-primary" class:disabled={installing}>
|
|
|
|
|
|
<Icon name="cloud-upload-alt" />
|
|
|
|
|
|
{installing
|
|
|
|
|
|
? t('admin.plugins_installing', 'Installing…')
|
|
|
|
|
|
: t('admin.plugins_upload', 'Upload .zip')}
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".zip,application/zip"
|
|
|
|
|
|
hidden
|
|
|
|
|
|
disabled={installing}
|
|
|
|
|
|
onchange={onInstallPlugin}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{#if installMsg}
|
|
|
|
|
|
<p class={installMsg.ok ? 'status--ok' : 'status--error'}>{installMsg.text}</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if plugins.length === 0}
|
|
|
|
|
|
<p class="status">{t('admin.no_plugins', 'No plugins installed.')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<table class="table">
|
|
|
|
|
|
<thead>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<tr>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<th>{t('admin.plugin', 'Plugin')}</th>
|
|
|
|
|
|
<th>{t('admin.plugins_col_id', 'ID')}</th>
|
|
|
|
|
|
<th>{t('admin.version', 'Version')}</th>
|
|
|
|
|
|
<th>{t('admin.plugins_col_events', 'Events')}</th>
|
|
|
|
|
|
<th>{t('admin.status', 'Status')}</th>
|
|
|
|
|
|
<th></th>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</tr>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{#each plugins as p (p.id)}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="user-cell">
|
|
|
|
|
|
<strong>{p.name}</strong>
|
|
|
|
|
|
{#if p.description}<span class="muted">{p.description}</span>{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><code>{p.id}</code></td>
|
|
|
|
|
|
<td>{p.version ?? '—'}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{#if p.subscriptions && p.subscriptions.length > 0}
|
|
|
|
|
|
<span class="events">{p.subscriptions.length}</span>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
—
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span class="badge badge--{p.enabled ? 'active' : 'inactive'}">
|
|
|
|
|
|
{p.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn"
|
|
|
|
|
|
title={t('admin.plugins_details', 'Logs & details')}
|
|
|
|
|
|
aria-label={t('admin.plugins_details', 'Logs & details')}
|
|
|
|
|
|
onclick={() => openLogs(p)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="list" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn {p.enabled ? '' : 'icon-btn--success'}"
|
|
|
|
|
|
title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
|
|
|
|
|
|
aria-label={p.enabled
|
|
|
|
|
|
? t('admin.disable', 'Disable')
|
|
|
|
|
|
: t('admin.enable', 'Enable')}
|
|
|
|
|
|
onclick={() => togglePlugin(p)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name={p.enabled ? 'pause' : 'play'} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="icon-btn icon-btn--danger"
|
|
|
|
|
|
title={t('common.delete', 'Delete')}
|
|
|
|
|
|
aria-label={t('common.delete', 'Delete')}
|
|
|
|
|
|
onclick={() => removePlugin(p)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="trash-alt" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{/if}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
|
|
|
|
|
|
<form id="create-user-form" onsubmit={submitCreate} class="form">
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.username', 'Username')}</span>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<input bind:value={newUser.username} minlength="3" required /></label
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
|
|
|
|
|
<label
|
2026-06-17 22:07:18 -06:00
|
|
|
|
><span
|
|
|
|
|
|
>{t('admin.email', 'Email')}
|
|
|
|
|
|
<span class="muted">({t('common.optional', 'optional')})</span></span
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
bind:value={newUser.email}
|
|
|
|
|
|
placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
|
|
|
|
|
|
/></label
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.password', 'Password')}</span>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<input type="password" bind:value={newUser.password} minlength="8" required /></label
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.role', 'Role')}</span>
|
|
|
|
|
|
<select bind:value={newUser.role}>
|
|
|
|
|
|
<option value="user">user</option>
|
|
|
|
|
|
<option value="admin">admin</option>
|
|
|
|
|
|
</select></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
2026-06-17 22:07:18 -06:00
|
|
|
|
><span>{t('admin.quota', 'Quota')}</span>
|
|
|
|
|
|
<div class="quota-input">
|
|
|
|
|
|
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} />
|
|
|
|
|
|
<select bind:value={newUser.quotaUnit}>
|
|
|
|
|
|
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
|
|
|
|
|
|
>{/each}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="muted">{t('admin.quota_unlimited_hint', '0 = unlimited')}</span></label
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{#if createError}<p class="status--error">{createError}</p>{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</form>
|
|
|
|
|
|
{#snippet footer()}
|
|
|
|
|
|
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}>
|
|
|
|
|
|
{creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Quota edit modal -->
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
open={quotaModal !== null}
|
|
|
|
|
|
title={t('admin.edit_quota_title', 'Edit quota')}
|
|
|
|
|
|
onclose={() => (quotaModal = null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if quotaModal}
|
|
|
|
|
|
<form
|
|
|
|
|
|
id="quota-form"
|
|
|
|
|
|
class="form"
|
|
|
|
|
|
onsubmit={(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
void saveQuota();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p class="muted">
|
|
|
|
|
|
{t('admin.quota_for', 'Quota for')} <strong>{quotaModal.username}</strong>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.quota', 'Quota')}</span>
|
|
|
|
|
|
<div class="quota-input">
|
|
|
|
|
|
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} />
|
|
|
|
|
|
<select bind:value={quotaModal.unit}>
|
|
|
|
|
|
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
|
|
|
|
|
|
>{/each}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="muted">{t('admin.quota_unlimited_hint', '0 = unlimited')}</span></label
|
|
|
|
|
|
>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#snippet footer()}
|
|
|
|
|
|
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
|
|
|
|
|
<button class="btn btn--primary" type="submit" form="quota-form">
|
|
|
|
|
|
{t('common.save', 'Save')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Reset-password modal -->
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
open={resetModal !== null}
|
|
|
|
|
|
title={t('admin.reset_password_title', 'Reset password')}
|
|
|
|
|
|
onclose={() => (resetModal = null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if resetModal}
|
|
|
|
|
|
<form id="reset-pw-form" class="form" onsubmit={submitReset}>
|
|
|
|
|
|
<p class="muted">
|
|
|
|
|
|
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.new_password', 'New password')}</span>
|
|
|
|
|
|
<input type="password" bind:value={resetPassword} minlength="8" required /></label
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if resetError}<p class="status--error">{resetError}</p>{/if}
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#snippet footer()}
|
|
|
|
|
|
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
|
|
|
|
|
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}>
|
|
|
|
|
|
{resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Styled confirm modal (replaces native confirm) -->
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
open={confirmState !== null}
|
|
|
|
|
|
title={t('common.confirm', 'Confirm')}
|
|
|
|
|
|
onclose={() => resolveConfirm(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p>{confirmState?.message}</p>
|
|
|
|
|
|
{#snippet footer()}
|
|
|
|
|
|
<button class="btn" onclick={() => resolveConfirm(false)}>{t('common.cancel', 'Cancel')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
<button class="btn btn--primary" onclick={() => resolveConfirm(true)}>
|
|
|
|
|
|
{t('common.confirm', 'Confirm')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
open={logsPlugin !== null}
|
|
|
|
|
|
title={logsPlugin?.name ?? t('admin.plugin_logs', 'Plugin logs')}
|
|
|
|
|
|
onclose={closeLogs}
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if logsPlugin}
|
|
|
|
|
|
<dl class="kv plugin-meta">
|
|
|
|
|
|
<dt>{t('admin.plugins_col_id', 'ID')}</dt>
|
|
|
|
|
|
<dd><code>{logsPlugin.id}</code></dd>
|
|
|
|
|
|
<dt>{t('admin.version', 'Version')}</dt>
|
|
|
|
|
|
<dd>{logsPlugin.version ?? '—'}</dd>
|
|
|
|
|
|
{#if logsPlugin.abi != null}
|
|
|
|
|
|
<dt>ABI</dt>
|
|
|
|
|
|
<dd>{logsPlugin.abi}</dd>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<dt>{t('admin.plugins_col_events', 'Events')}</dt>
|
|
|
|
|
|
<dd>
|
|
|
|
|
|
{#if logsPlugin.subscriptions && logsPlugin.subscriptions.length > 0}
|
|
|
|
|
|
{#each logsPlugin.subscriptions as ev (ev)}<code class="event-tag">{ev}</code>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
—
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
<dt>{t('admin.status', 'Status')}</dt>
|
|
|
|
|
|
<dd>
|
|
|
|
|
|
<span class="badge badge--{logsPlugin.enabled ? 'active' : 'inactive'}">
|
|
|
|
|
|
{logsPlugin.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
{#if retention}
|
|
|
|
|
|
<form class="form retention-form" onsubmit={(e) => (e.preventDefault(), saveRetention())}>
|
|
|
|
|
|
<h3>{t('admin.plugins_retention', 'Log retention')}</h3>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span>
|
|
|
|
|
|
<input type="number" min="0" bind:value={retentionDays} /></label
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span>
|
|
|
|
|
|
<input type="number" min="0" bind:value={retentionMb} /></label
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if}
|
|
|
|
|
|
<button class="btn btn-secondary" type="submit"
|
|
|
|
|
|
>{t('admin.plugins_retention_save', 'Save retention')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="logs-toolbar">
|
|
|
|
|
|
<select bind:value={logsLevel} onchange={reloadLogsFromStart}>
|
|
|
|
|
|
<option value="">{t('admin.logs_all', 'All levels')}</option>
|
|
|
|
|
|
<option value="info">info</option>
|
|
|
|
|
|
<option value="warn">warn</option>
|
|
|
|
|
|
<option value="error">error</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<input
|
|
|
|
|
|
placeholder={t('admin.logs_search', 'Search…')}
|
|
|
|
|
|
bind:value={logsSearch}
|
|
|
|
|
|
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button class="btn btn-secondary" onclick={reloadLogsFromStart}
|
|
|
|
|
|
>{t('common.search', 'Search')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
<label class="live-toggle">
|
|
|
|
|
|
<input type="checkbox" bind:checked={logsLive} onchange={toggleLive} />
|
|
|
|
|
|
<span>{t('admin.logs_live', 'Live')}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{#if logsLoading}
|
|
|
|
|
|
<p class="status">{t('common.loading', 'Loading…')}</p>
|
|
|
|
|
|
{:else if logs.length === 0}
|
|
|
|
|
|
<p class="status">{t('admin.logs_empty', 'No log entries.')}</p>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<div class="logs-table-wrap">
|
|
|
|
|
|
<table class="table logs-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>{t('admin.logs_time', 'Time')}</th>
|
|
|
|
|
|
<th>{t('admin.logs_level', 'Level')}</th>
|
|
|
|
|
|
<th>{t('admin.logs_kind', 'Kind')}</th>
|
|
|
|
|
|
<th>{t('admin.logs_invocation', 'Invocation')}</th>
|
|
|
|
|
|
<th>{t('admin.logs_message', 'Message')}</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{#each logs as entry, i (i)}
|
|
|
|
|
|
<tr class="log-row log-row--{(entry.level ?? 'info').toLowerCase()}">
|
|
|
|
|
|
<td class="log-time">{timeAgo(entry.ts ?? entry.timestamp)}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span class="log-level log-level--{(entry.level ?? 'info').toLowerCase()}"
|
|
|
|
|
|
>{entry.level ?? 'info'}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><code>{logKind(entry)}</code></td>
|
|
|
|
|
|
<td><code class="log-inv">{entry.invocation_id ?? '—'}</code></td>
|
|
|
|
|
|
<td class="log-msg">{logMsg(entry)}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<div class="pager logs-pager">
|
|
|
|
|
|
<button class="btn" disabled={logsPage === 0} onclick={logsPrev}>‹</button>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{#if logsTotal === 0}
|
|
|
|
|
|
{t('admin.logs_empty', 'No log entries.')}
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'admin.logs_showing',
|
|
|
|
|
|
{
|
|
|
|
|
|
from: logsPage * LOGS_PAGE_SIZE + 1,
|
|
|
|
|
|
to: Math.min((logsPage + 1) * LOGS_PAGE_SIZE, logsTotal),
|
|
|
|
|
|
total: logsTotal
|
|
|
|
|
|
},
|
|
|
|
|
|
'Showing {{from}}–{{to}} of {{total}}'
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button class="btn" disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal} onclick={logsNext}
|
|
|
|
|
|
>›</button
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{#snippet footer()}
|
|
|
|
|
|
<button class="btn btn-danger" onclick={purgeLogs}
|
|
|
|
|
|
>{t('admin.plugins_clear_logs', 'Clear logs')}</button
|
|
|
|
|
|
>
|
|
|
|
|
|
<button class="btn btn-secondary" onclick={closeLogs}>
|
|
|
|
|
|
{t('common.close', 'Close')}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</button>
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.logs-toolbar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
margin-bottom: var(--space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logs-toolbar input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
padding: var(--space-2) var(--space-3);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-input);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.live-toggle {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-1);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logs-table-wrap {
|
|
|
|
|
|
max-height: 50vh;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logs-table {
|
|
|
|
|
|
font-family: var(--font-mono, monospace);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-time {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-inv {
|
|
|
|
|
|
font-size: var(--text-xs, 0.7rem);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-level {
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
font-size: var(--text-xs, 0.7rem);
|
|
|
|
|
|
font-weight: var(--weight-semibold, 600);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-level--error {
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-level--warn {
|
|
|
|
|
|
color: var(--color-warning-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-msg {
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logs-pager {
|
|
|
|
|
|
margin-top: var(--space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card {
|
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
padding: var(--space-5);
|
|
|
|
|
|
margin-bottom: var(--space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card h2 {
|
|
|
|
|
|
margin: 0 0 var(--space-3);
|
|
|
|
|
|
font-size: 1.125rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.checkbox {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
|
|
|
|
|
gap: var(--space-3);
|
|
|
|
|
|
margin-bottom: var(--space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--space-1);
|
|
|
|
|
|
padding: var(--space-4);
|
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-num {
|
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
|
font-weight: var(--weight-bold);
|
|
|
|
|
|
color: var(--color-text-heading);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-bar {
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
margin-bottom: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-fill {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-fill--warn {
|
|
|
|
|
|
background: var(--color-warning-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-fill--danger {
|
|
|
|
|
|
background: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.kv {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: auto 1fr;
|
|
|
|
|
|
gap: var(--space-1) var(--space-4);
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.kv dt {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.kv dd {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
padding: 0.05rem 0.4rem;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
font-size: var(--text-xs, 0.7rem);
|
|
|
|
|
|
font-weight: var(--weight-semibold, 600);
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--env {
|
|
|
|
|
|
margin-left: var(--space-2);
|
|
|
|
|
|
background: var(--color-warning-bg);
|
|
|
|
|
|
color: var(--color-warning-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--oidc {
|
|
|
|
|
|
background: var(--color-info-bg);
|
|
|
|
|
|
color: var(--color-info-text);
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--local {
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--active {
|
|
|
|
|
|
background: var(--color-success-bg);
|
|
|
|
|
|
color: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--inactive {
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--admin {
|
|
|
|
|
|
background: var(--color-info-bg);
|
|
|
|
|
|
color: var(--color-info-text);
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--user {
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge--self {
|
|
|
|
|
|
margin-left: var(--space-1);
|
|
|
|
|
|
background: var(--color-warning-bg);
|
|
|
|
|
|
color: var(--color-warning-text);
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Enabled/disabled feature flag indicator on the dashboard cards. */
|
|
|
|
|
|
.ds-flag {
|
|
|
|
|
|
font-size: 1.125rem;
|
|
|
|
|
|
font-weight: var(--weight-bold);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ds-flag--on {
|
|
|
|
|
|
color: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.warn-card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.warn-card--warn {
|
|
|
|
|
|
border-color: var(--color-warning-text);
|
|
|
|
|
|
color: var(--color-warning-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.warn-card--danger {
|
|
|
|
|
|
border-color: var(--color-danger-text);
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Per-user storage-usage progress bar in the users table. */
|
|
|
|
|
|
.quota-cell {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--space-1);
|
|
|
|
|
|
min-width: 9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.quota-bar {
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.quota-fill {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.quota-fill--warn {
|
|
|
|
|
|
background: var(--color-warning-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.quota-fill--danger {
|
|
|
|
|
|
background: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.quota-input {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.quota-input input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Icon-only row actions with hover tooltips (title attr). */
|
|
|
|
|
|
.icon-btn {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 2rem;
|
|
|
|
|
|
height: 2rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.icon-btn:disabled {
|
|
|
|
|
|
opacity: 0.45;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.icon-btn--danger {
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.icon-btn--success {
|
|
|
|
|
|
color: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.secret-hint {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
margin-top: var(--space-1);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.registration-warning {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
margin-top: var(--space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.alert--warn {
|
|
|
|
|
|
padding: var(--space-2) var(--space-3);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-warning-bg);
|
|
|
|
|
|
color: var(--color-warning-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Discovery / verify result panels. */
|
|
|
|
|
|
.discovery-result {
|
|
|
|
|
|
margin-top: var(--space-2);
|
|
|
|
|
|
padding: var(--space-3);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.discovery-result strong {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.discovery-result--ok {
|
|
|
|
|
|
border-color: var(--color-success-text);
|
|
|
|
|
|
color: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.discovery-result--fail {
|
|
|
|
|
|
border-color: var(--color-danger-text);
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.discovery-result .kv {
|
|
|
|
|
|
margin-top: var(--space-2);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.callback-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.install-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: var(--space-3);
|
|
|
|
|
|
padding: var(--space-3);
|
|
|
|
|
|
border: 1px dashed var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
margin-bottom: var(--space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.install-bar .muted {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.install-bar .btn.disabled {
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.events {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
min-width: 1.4rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 0 0.35rem;
|
|
|
|
|
|
border-radius: var(--radius-pill, 999px);
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.plugin-meta {
|
|
|
|
|
|
margin-bottom: var(--space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.event-tag {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
margin: 0 0.15rem 0.15rem 0;
|
|
|
|
|
|
padding: 0.05rem 0.35rem;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.retention-form {
|
|
|
|
|
|
border-top: 1px solid var(--color-border);
|
|
|
|
|
|
padding-top: var(--space-3);
|
|
|
|
|
|
margin-bottom: var(--space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.retention-form h3 {
|
|
|
|
|
|
margin: 0 0 var(--space-2);
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mig-failed {
|
|
|
|
|
|
margin-top: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mig-failed__list {
|
|
|
|
|
|
max-height: 12rem;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
padding: var(--space-2);
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
font-size: var(--text-xs, 0.75rem);
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.smtp-test {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.smtp-test input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
padding: var(--space-2) var(--space-3);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-input);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status--ok {
|
|
|
|
|
|
color: var(--color-success-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
.admin {
|
|
|
|
|
|
max-width: 64rem;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
padding: 1.5rem 1rem;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tabs {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tabs button {
|
|
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
border-bottom: 2px solid transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tabs button[aria-selected='true'] {
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
border-bottom-color: var(--color-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table th,
|
|
|
|
|
|
.table td {
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
padding: 0.5rem 0.625rem;
|
|
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-cell {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.muted {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pager {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form label {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form input,
|
|
|
|
|
|
.form select {
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-input);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
padding: 0.5rem 0.875rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn--primary {
|
|
|
|
|
|
background: var(--color-primary);
|
|
|
|
|
|
color: var(--color-text-light);
|
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
padding: 2rem 0;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status--error {
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.link-btn {
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.link-btn--danger {
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|