HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1396 lines
35 KiB
Svelte
1396 lines
35 KiB
Svelte
<script lang="ts">
|
||
import { base } from '$app/paths';
|
||
import { goto } from '$app/navigation';
|
||
import { page } from '$app/state';
|
||
import {
|
||
api,
|
||
ApiError,
|
||
type AdminDto,
|
||
type App,
|
||
type AppDomain,
|
||
type AppMemberDto,
|
||
type AppRole,
|
||
type Script,
|
||
type Trigger
|
||
} from '$lib/api';
|
||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||
import ActionMenu from '$lib/ActionMenu.svelte';
|
||
import RoleChip from '$lib/RoleChip.svelte';
|
||
import { currentUser } from '$lib/auth';
|
||
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||
|
||
const me = $derived($currentUser);
|
||
|
||
const SAMPLE_SOURCE =
|
||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||
|
||
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers';
|
||
|
||
// Common IANA timezones offered in the cron form dropdown. Not
|
||
// exhaustive — the backend validates any IANA name via chrono-tz.
|
||
const COMMON_TIMEZONES = [
|
||
'UTC',
|
||
'America/Los_Angeles',
|
||
'America/Denver',
|
||
'America/Chicago',
|
||
'America/New_York',
|
||
'America/Sao_Paulo',
|
||
'Europe/London',
|
||
'Europe/Berlin',
|
||
'Europe/Paris',
|
||
'Europe/Moscow',
|
||
'Asia/Kolkata',
|
||
'Asia/Shanghai',
|
||
'Asia/Tokyo',
|
||
'Australia/Sydney'
|
||
];
|
||
|
||
let slug = $derived(page.params.slug ?? '');
|
||
let app = $state<App | null>(null);
|
||
let myRole = $state<AppRole | null>(null);
|
||
let loadError = $state<string | null>(null);
|
||
let loading = $state(true);
|
||
let activeTab = $state<Tab>('scripts');
|
||
|
||
let scripts = $state<Script[]>([]);
|
||
let domains = $state<AppDomain[]>([]);
|
||
let members = $state<AppMemberDto[]>([]);
|
||
|
||
/// v1.1.1 dead-letters surface — design notes §4 mandates the
|
||
/// dashboard surface this since there's no default handler.
|
||
let unresolvedDeadLetters = $state<number>(0);
|
||
async function loadDeadLetterCount(idOrSlug: string) {
|
||
try {
|
||
const r = await api.deadLetters.count(idOrSlug);
|
||
unresolvedDeadLetters = r.unresolved;
|
||
} catch {
|
||
// Non-fatal: the page renders fine without the badge if
|
||
// the count endpoint is unreachable (e.g. older server).
|
||
unresolvedDeadLetters = 0;
|
||
}
|
||
}
|
||
|
||
// Derive UI gates from the capabilities helper so the rules stay
|
||
// in lockstep with the backend's `can()`. canAdminApp also covers
|
||
// the Members + Settings + Domains-mutation tabs; canWriteApp
|
||
// covers New script.
|
||
const canWrite = $derived(canWriteApp(me, myRole));
|
||
const canAdmin = $derived(canAdminApp(me, myRole));
|
||
|
||
// Script create
|
||
let showCreateScript = $state(false);
|
||
let createScriptName = $state('');
|
||
let createScriptDescription = $state('');
|
||
let createScriptSource = $state(SAMPLE_SOURCE);
|
||
// v1.1.3: endpoint (default — handles routes/triggers) vs module
|
||
// (library imported by other scripts). Modules cannot be bound to
|
||
// routes or used as trigger targets.
|
||
let createScriptKind = $state<'endpoint' | 'module'>('endpoint');
|
||
let creatingScript = $state(false);
|
||
let createScriptError = $state<string | null>(null);
|
||
|
||
// Domain create
|
||
let createDomainPattern = $state('');
|
||
let creatingDomain = $state(false);
|
||
let createDomainError = $state<string | null>(null);
|
||
|
||
// Settings
|
||
let editName = $state('');
|
||
let editDescription = $state('');
|
||
let editSlug = $state('');
|
||
let savingSettings = $state(false);
|
||
let settingsError = $state<string | null>(null);
|
||
let slugTakeoverNeeded = $state<App | null>(null);
|
||
|
||
// Delete confirmations
|
||
let confirmingDeleteApp = $state(false);
|
||
let deletingApp = $state(false);
|
||
let deleteAppError = $state<string | null>(null);
|
||
let domainToRemove = $state<AppDomain | null>(null);
|
||
let removingDomain = $state(false);
|
||
let removeDomainError = $state<string | null>(null);
|
||
|
||
// Triggers tab (v1.1.4 — cron triggers). Admin-gated, like Members.
|
||
let triggers = $state<Trigger[]>([]);
|
||
let createCronScriptId = $state('');
|
||
let createCronSchedule = $state('0 0 9 * * MON-FRI');
|
||
let createCronTimezone = $state('UTC');
|
||
let creatingCron = $state(false);
|
||
let createCronError = $state<string | null>(null);
|
||
let triggerToRemove = $state<Trigger | null>(null);
|
||
let removingTrigger = $state(false);
|
||
// Endpoint scripts only — modules can't be trigger targets.
|
||
const endpointScripts = $derived(scripts.filter((s) => s.kind === 'endpoint'));
|
||
|
||
async function loadTriggers(idOrSlug: string) {
|
||
try {
|
||
const r = await api.triggers.list(idOrSlug);
|
||
triggers = r.triggers;
|
||
} catch {
|
||
triggers = [];
|
||
}
|
||
}
|
||
|
||
async function submitCreateCron(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!app) return;
|
||
creatingCron = true;
|
||
createCronError = null;
|
||
try {
|
||
await api.triggers.createCron(app.id, {
|
||
script_id: createCronScriptId,
|
||
schedule: createCronSchedule.trim(),
|
||
timezone: createCronTimezone
|
||
});
|
||
createCronScriptId = '';
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createCronError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
creatingCron = false;
|
||
}
|
||
}
|
||
|
||
async function confirmRemoveTrigger() {
|
||
if (!app || !triggerToRemove) return;
|
||
removingTrigger = true;
|
||
try {
|
||
await api.triggers.remove(app.id, triggerToRemove.id);
|
||
triggerToRemove = null;
|
||
await loadTriggers(app.id);
|
||
} catch (err) {
|
||
createCronError =
|
||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
removingTrigger = false;
|
||
}
|
||
}
|
||
|
||
// Members tab
|
||
let eligibleUsers = $state<AdminDto[]>([]);
|
||
let eligibleLoadError = $state<string | null>(null);
|
||
let addMemberUserId = $state('');
|
||
let addMemberRole = $state<AppRole>('viewer');
|
||
let addingMember = $state(false);
|
||
let addMemberError = $state<string | null>(null);
|
||
let memberToRemove = $state<AppMemberDto | null>(null);
|
||
let removingMember = $state(false);
|
||
let removeMemberError = $state<string | null>(null);
|
||
let roleChangeBusy = $state<string | null>(null);
|
||
let memberActionError = $state<string | null>(null);
|
||
|
||
async function loadApp() {
|
||
loading = true;
|
||
loadError = null;
|
||
try {
|
||
const fetched = await api.apps.get(slug);
|
||
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
|
||
return;
|
||
}
|
||
app = {
|
||
id: fetched.id,
|
||
slug: fetched.slug,
|
||
name: fetched.name,
|
||
description: fetched.description,
|
||
created_at: fetched.created_at,
|
||
updated_at: fetched.updated_at
|
||
};
|
||
myRole = fetched.my_role;
|
||
editName = app.name;
|
||
editDescription = app.description ?? '';
|
||
editSlug = app.slug;
|
||
const loaders: Promise<unknown>[] = [
|
||
loadScripts(app.id),
|
||
loadDomains(app.id),
|
||
loadDeadLetterCount(app.id)
|
||
];
|
||
if (canAdmin) {
|
||
loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id));
|
||
}
|
||
await Promise.all(loaders);
|
||
} catch (e) {
|
||
loadError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
async function loadScripts(appId: string) {
|
||
try {
|
||
scripts = await api.scripts.list({ app: appId });
|
||
} catch (e) {
|
||
scripts = [];
|
||
loadError = e instanceof Error ? e.message : String(e);
|
||
}
|
||
}
|
||
|
||
async function loadDomains(appId: string) {
|
||
try {
|
||
domains = await api.domains.listForApp(appId);
|
||
} catch (e) {
|
||
domains = [];
|
||
loadError = e instanceof Error ? e.message : String(e);
|
||
}
|
||
}
|
||
|
||
async function loadMembers(appId: string) {
|
||
try {
|
||
members = await api.appMembers.list(appId);
|
||
} catch (e) {
|
||
members = [];
|
||
memberActionError = e instanceof Error ? e.message : String(e);
|
||
}
|
||
}
|
||
|
||
async function loadEligibleUsers() {
|
||
eligibleLoadError = null;
|
||
try {
|
||
const all = await api.admins.list();
|
||
// Only inactive=false members are valid invite targets — the
|
||
// API rejects everyone else anyway, so filter upfront.
|
||
eligibleUsers = all.filter(
|
||
(u) => u.is_active && u.instance_role === 'member'
|
||
);
|
||
} catch (e) {
|
||
eligibleUsers = [];
|
||
// member-with-app_admin can hit /apps/.../members but cannot
|
||
// browse /admins (gated on InstanceManageUsers). The add form
|
||
// will render disabled with the explanatory message below.
|
||
eligibleLoadError =
|
||
e instanceof ApiError && e.status === 403
|
||
? 'Only instance owners/admins can browse the user directory to invite new members.'
|
||
: e instanceof Error
|
||
? e.message
|
||
: String(e);
|
||
}
|
||
}
|
||
|
||
const eligibleAfterFilter = $derived(
|
||
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
|
||
);
|
||
|
||
async function submitCreateScript(event: Event) {
|
||
event.preventDefault();
|
||
if (!app) return;
|
||
creatingScript = true;
|
||
createScriptError = null;
|
||
try {
|
||
await api.scripts.create({
|
||
app_id: app.id,
|
||
name: createScriptName.trim(),
|
||
description: createScriptDescription.trim() || null,
|
||
source: createScriptSource,
|
||
kind: createScriptKind
|
||
});
|
||
showCreateScript = false;
|
||
createScriptName = '';
|
||
createScriptDescription = '';
|
||
createScriptSource = SAMPLE_SOURCE;
|
||
createScriptKind = 'endpoint';
|
||
await loadScripts(app.id);
|
||
} catch (e) {
|
||
createScriptError = e instanceof Error ? e.message : String(e);
|
||
if (e instanceof ApiError && e.status === 422) {
|
||
createScriptError = `Validation: ${createScriptError}`;
|
||
}
|
||
} finally {
|
||
creatingScript = false;
|
||
}
|
||
}
|
||
|
||
async function submitCreateDomain(event: Event) {
|
||
event.preventDefault();
|
||
if (!app) return;
|
||
creatingDomain = true;
|
||
createDomainError = null;
|
||
try {
|
||
await api.domains.create(app.id, createDomainPattern.trim());
|
||
createDomainPattern = '';
|
||
await loadDomains(app.id);
|
||
} catch (e) {
|
||
createDomainError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
creatingDomain = false;
|
||
}
|
||
}
|
||
|
||
function askRemoveDomain(d: AppDomain) {
|
||
removeDomainError = null;
|
||
domainToRemove = d;
|
||
}
|
||
|
||
async function confirmRemoveDomain() {
|
||
if (!app || !domainToRemove) return;
|
||
removingDomain = true;
|
||
removeDomainError = null;
|
||
try {
|
||
await api.domains.remove(app.id, domainToRemove.id);
|
||
domainToRemove = null;
|
||
await loadDomains(app.id);
|
||
} catch (e) {
|
||
removeDomainError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
removingDomain = false;
|
||
}
|
||
}
|
||
|
||
async function saveSettings(event: Event, forceTakeover = false) {
|
||
event.preventDefault();
|
||
if (!app) return;
|
||
savingSettings = true;
|
||
settingsError = null;
|
||
if (!forceTakeover) slugTakeoverNeeded = null;
|
||
try {
|
||
const slugChanged = editSlug.trim() !== app.slug;
|
||
const updated = await api.apps.update(app.id, {
|
||
name: editName.trim() !== app.name ? editName.trim() : undefined,
|
||
description:
|
||
editDescription !== (app.description ?? '')
|
||
? editDescription || null
|
||
: undefined,
|
||
slug: slugChanged ? editSlug.trim() : undefined,
|
||
force_takeover: forceTakeover || undefined
|
||
});
|
||
if (slugChanged) {
|
||
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
|
||
return;
|
||
}
|
||
app = updated;
|
||
} catch (e) {
|
||
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||
if (body.conflict_kind === 'historical' && body.current_app) {
|
||
slugTakeoverNeeded = body.current_app;
|
||
settingsError = null;
|
||
return;
|
||
}
|
||
}
|
||
settingsError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
savingSettings = false;
|
||
}
|
||
}
|
||
|
||
async function submitAddMember(event: Event) {
|
||
event.preventDefault();
|
||
if (!app || !addMemberUserId) return;
|
||
addingMember = true;
|
||
addMemberError = null;
|
||
try {
|
||
await api.appMembers.add(app.id, {
|
||
user_id: addMemberUserId,
|
||
role: addMemberRole
|
||
});
|
||
addMemberUserId = '';
|
||
addMemberRole = 'viewer';
|
||
await loadMembers(app.id);
|
||
} catch (e) {
|
||
addMemberError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
addingMember = false;
|
||
}
|
||
}
|
||
|
||
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
|
||
if (!app || member.role === role) return;
|
||
roleChangeBusy = member.user_id;
|
||
memberActionError = null;
|
||
try {
|
||
await api.appMembers.setRole(app.id, member.user_id, role);
|
||
await loadMembers(app.id);
|
||
} catch (e) {
|
||
memberActionError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
roleChangeBusy = null;
|
||
}
|
||
}
|
||
|
||
function askRemoveMember(member: AppMemberDto) {
|
||
removeMemberError = null;
|
||
memberToRemove = member;
|
||
}
|
||
|
||
async function confirmRemoveMember() {
|
||
if (!app || !memberToRemove) return;
|
||
removingMember = true;
|
||
removeMemberError = null;
|
||
try {
|
||
const removedSelf = !!me && memberToRemove.user_id === me.id;
|
||
await api.appMembers.remove(app.id, memberToRemove.user_id);
|
||
memberToRemove = null;
|
||
if (removedSelf) {
|
||
// We just revoked our own access to this app; the next
|
||
// fetch of /apps/{slug} would 403. Bounce back to the
|
||
// apps list rather than render a broken tab.
|
||
await goto(`${base}/apps`);
|
||
return;
|
||
}
|
||
await loadMembers(app.id);
|
||
} catch (e) {
|
||
removeMemberError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
removingMember = false;
|
||
}
|
||
}
|
||
|
||
function shortDate(iso: string): string {
|
||
try {
|
||
return new Date(iso).toLocaleDateString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function askDeleteApp() {
|
||
deleteAppError = null;
|
||
confirmingDeleteApp = true;
|
||
}
|
||
|
||
async function confirmDeleteApp() {
|
||
if (!app) return;
|
||
deletingApp = true;
|
||
deleteAppError = null;
|
||
try {
|
||
// force=true cascades scripts (and thereby their routes +
|
||
// execution logs); domains and slug-history rows cascade off
|
||
// the app row itself.
|
||
await api.apps.remove(app.id, { force: true });
|
||
await goto(`${base}/apps`);
|
||
} catch (e) {
|
||
deleteAppError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
deletingApp = false;
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
void loadApp();
|
||
});
|
||
|
||
// Defense-in-depth: a viewer / editor following a stale link to
|
||
// the Settings or Members tab gets bounced back to Scripts. The
|
||
// backend still 403s the underlying calls, but no point showing an
|
||
// empty tab.
|
||
$effect(() => {
|
||
if (
|
||
!canAdmin &&
|
||
(activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers')
|
||
) {
|
||
activeTab = 'scripts';
|
||
}
|
||
});
|
||
</script>
|
||
|
||
{#if loading && !app}
|
||
<p class="muted">Loading…</p>
|
||
{:else if loadError && !app}
|
||
<div class="error">
|
||
<strong>Could not load app.</strong>
|
||
<p>{loadError}</p>
|
||
<a href="{base}/apps">Back to apps</a>
|
||
</div>
|
||
{:else if app}
|
||
<header class="page-header">
|
||
<div>
|
||
<div class="breadcrumb">
|
||
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
|
||
</div>
|
||
<h1>{app.name}</h1>
|
||
{#if app.description}<p class="muted">{app.description}</p>{/if}
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="tabs">
|
||
<button
|
||
type="button"
|
||
class:active={activeTab === 'scripts'}
|
||
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
|
||
>
|
||
<button
|
||
type="button"
|
||
class:active={activeTab === 'domains'}
|
||
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||
>
|
||
{#if canAdmin}
|
||
<button
|
||
type="button"
|
||
class:active={activeTab === 'members'}
|
||
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
||
>
|
||
<button
|
||
type="button"
|
||
class:active={activeTab === 'triggers'}
|
||
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
|
||
>
|
||
<button
|
||
type="button"
|
||
class:active={activeTab === 'settings'}
|
||
onclick={() => (activeTab = 'settings')}>Settings</button
|
||
>
|
||
<a
|
||
class="tab-link"
|
||
href="{base}/apps/{slug}/dead-letters"
|
||
title="Dead letters — replay or resolve events that exhausted their retry policy"
|
||
>
|
||
Dead letters
|
||
{#if unresolvedDeadLetters > 0}
|
||
<span class="dl-badge">{unresolvedDeadLetters}</span>
|
||
{/if}
|
||
</a>
|
||
{/if}
|
||
</nav>
|
||
|
||
{#if activeTab === 'scripts'}
|
||
<section>
|
||
<div class="row">
|
||
<h2>Scripts</h2>
|
||
{#if canWrite}
|
||
<button
|
||
type="button"
|
||
onclick={() => (showCreateScript = !showCreateScript)}
|
||
>
|
||
{showCreateScript ? 'Cancel' : 'New script'}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if showCreateScript && canWrite}
|
||
<form class="create-form" onsubmit={submitCreateScript}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Name</span>
|
||
<input bind:value={createScriptName} required placeholder="echo" />
|
||
</label>
|
||
<label>
|
||
<span>Kind</span>
|
||
<select bind:value={createScriptKind}>
|
||
<option value="endpoint">Endpoint (handles HTTP / triggers)</option>
|
||
<option value="module">Module (imported by other scripts)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Description</span>
|
||
<input bind:value={createScriptDescription} placeholder="optional" />
|
||
</label>
|
||
</div>
|
||
<label class="full">
|
||
<span>Source (Rhai)</span>
|
||
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
|
||
</label>
|
||
{#if createScriptKind === 'module'}
|
||
<p class="muted small">
|
||
Modules expose <code>fn</code> and <code>const</code> declarations to other
|
||
scripts via <code>import "name" as alias;</code>. They cannot be bound to
|
||
routes or used as trigger targets.
|
||
</p>
|
||
{/if}
|
||
{#if createScriptError}
|
||
<div class="error">{createScriptError}</div>
|
||
{/if}
|
||
<div class="actions">
|
||
<button type="submit" disabled={creatingScript}>
|
||
{creatingScript ? 'Creating…' : 'Create script'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
|
||
{#if scripts.length === 0}
|
||
<p class="muted">No scripts in this app yet.</p>
|
||
{:else}
|
||
<ul class="list">
|
||
{#each scripts as script (script.id)}
|
||
<li>
|
||
<a href="{base}/scripts/{script.id}">
|
||
<div class="primary">
|
||
<strong>{script.name}</strong>
|
||
<span class="muted">v{script.version}</span>
|
||
{#if script.kind === 'module'}
|
||
<span class="kind-badge kind-module" title="Library imported by other scripts">module</span>
|
||
{:else}
|
||
<span class="kind-badge kind-endpoint" title="Handles HTTP routes and trigger events">endpoint</span>
|
||
{/if}
|
||
</div>
|
||
<div class="secondary muted">{script.description ?? '—'}</div>
|
||
</a>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
{:else if activeTab === 'domains'}
|
||
<section>
|
||
<h2>Domain claims</h2>
|
||
<p class="muted">
|
||
Hosts this app answers on. Routes inside this app can only bind to
|
||
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
||
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||
</p>
|
||
{#if canAdmin}
|
||
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||
<input
|
||
bind:value={createDomainPattern}
|
||
required
|
||
placeholder="app.example.com"
|
||
/>
|
||
<button type="submit" disabled={creatingDomain}>
|
||
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||
</button>
|
||
</form>
|
||
{#if createDomainError}
|
||
<div class="error">{createDomainError}</div>
|
||
{/if}
|
||
{/if}
|
||
{#if domains.length === 0}
|
||
<p class="muted">No domain claims yet.</p>
|
||
{:else}
|
||
<ul class="list">
|
||
{#each domains as d (d.id)}
|
||
<li class="domain-row">
|
||
<div>
|
||
<code>{d.pattern}</code>
|
||
<span class="muted">— {d.shape}</span>
|
||
</div>
|
||
{#if canAdmin}
|
||
<button
|
||
type="button"
|
||
class="secondary danger"
|
||
onclick={() => askRemoveDomain(d)}
|
||
>
|
||
Delete
|
||
</button>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
{:else if activeTab === 'members' && canAdmin}
|
||
<section>
|
||
<h2>Members</h2>
|
||
<p class="muted">
|
||
Users with explicit access to this app. Instance owners and admins
|
||
already have implicit access — they are not listed here. Use the Users
|
||
page to invite a <code>member</code> first, then grant them app access
|
||
below.
|
||
</p>
|
||
|
||
<form class="create-form" onsubmit={submitAddMember}>
|
||
<div class="row">
|
||
<label class="grow">
|
||
<span>User</span>
|
||
<select
|
||
bind:value={addMemberUserId}
|
||
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
|
||
required
|
||
>
|
||
<option value="" disabled>Pick a member to invite…</option>
|
||
{#each eligibleAfterFilter as u (u.id)}
|
||
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Role</span>
|
||
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
|
||
<option value="viewer">viewer</option>
|
||
<option value="editor">editor</option>
|
||
<option value="app_admin">app admin</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{#if eligibleLoadError}
|
||
<p class="muted">{eligibleLoadError}</p>
|
||
{:else if eligibleAfterFilter.length === 0}
|
||
<p class="muted">
|
||
No eligible users to invite. Create a <code>member</code> on the Users
|
||
page first.
|
||
</p>
|
||
{/if}
|
||
{#if addMemberError}
|
||
<div class="error">{addMemberError}</div>
|
||
{/if}
|
||
<div class="actions">
|
||
<button
|
||
type="submit"
|
||
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
|
||
>
|
||
{addingMember ? 'Adding…' : 'Add member'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{#if memberActionError}
|
||
<div class="error">{memberActionError}</div>
|
||
{/if}
|
||
|
||
{#if members.length === 0}
|
||
<p class="muted">No explicit members yet.</p>
|
||
{:else}
|
||
<div class="table">
|
||
<div class="row head-row">
|
||
<div>User</div>
|
||
<div>Instance</div>
|
||
<div>App role</div>
|
||
<div>Joined</div>
|
||
<div class="actions-col"></div>
|
||
</div>
|
||
{#each members as m (m.user_id)}
|
||
<div class="row member-row" class:inactive={!m.is_active}>
|
||
<div>
|
||
<strong>{m.username}</strong>
|
||
{#if m.email}<span class="muted">{m.email}</span>{/if}
|
||
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
|
||
</div>
|
||
<div><RoleChip role={m.instance_role} size="sm" /></div>
|
||
<div><RoleChip appRole={m.role} size="sm" /></div>
|
||
<div>{shortDate(m.created_at)}</div>
|
||
<div class="actions-col">
|
||
<ActionMenu
|
||
label="Member actions for {m.username}"
|
||
items={[
|
||
{
|
||
label: 'Make app admin',
|
||
disabled:
|
||
m.role === 'app_admin' || roleChangeBusy === m.user_id,
|
||
onClick: () => changeMemberRole(m, 'app_admin')
|
||
},
|
||
{
|
||
label: 'Make editor',
|
||
disabled:
|
||
m.role === 'editor' || roleChangeBusy === m.user_id,
|
||
onClick: () => changeMemberRole(m, 'editor')
|
||
},
|
||
{
|
||
label: 'Make viewer',
|
||
disabled:
|
||
m.role === 'viewer' || roleChangeBusy === m.user_id,
|
||
onClick: () => changeMemberRole(m, 'viewer')
|
||
},
|
||
{
|
||
label: 'Remove from app',
|
||
danger: true,
|
||
onClick: () => askRemoveMember(m)
|
||
}
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
{:else if activeTab === 'triggers' && canAdmin}
|
||
<section>
|
||
<h2>Cron triggers</h2>
|
||
<p class="muted">
|
||
Run an endpoint script on a schedule. Schedules are 6-field cron
|
||
expressions (with seconds): <code>sec min hour day-of-month month day-of-week</code>.
|
||
The timezone disambiguates schedules like "every weekday at 9am".
|
||
</p>
|
||
|
||
<form class="create-form" onsubmit={submitCreateCron}>
|
||
<div class="row">
|
||
<label>
|
||
<span>Target script</span>
|
||
<select bind:value={createCronScriptId} required>
|
||
<option value="" disabled>Select an endpoint script…</option>
|
||
{#each endpointScripts as s (s.id)}
|
||
<option value={s.id}>{s.name}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Schedule</span>
|
||
<input
|
||
bind:value={createCronSchedule}
|
||
required
|
||
placeholder="0 0 9 * * MON-FRI"
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Timezone</span>
|
||
<select bind:value={createCronTimezone}>
|
||
{#each COMMON_TIMEZONES as tz (tz)}
|
||
<option value={tz}>{tz}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{#if endpointScripts.length === 0}
|
||
<p class="muted small">
|
||
This app has no endpoint scripts yet — create one first (modules
|
||
can't be trigger targets).
|
||
</p>
|
||
{/if}
|
||
{#if createCronError}
|
||
<div class="error">{createCronError}</div>
|
||
{/if}
|
||
<div class="actions">
|
||
<button type="submit" disabled={creatingCron || !createCronScriptId}>
|
||
{creatingCron ? 'Creating…' : 'Create cron trigger'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{#if triggers.length === 0}
|
||
<p class="muted">No triggers in this app yet.</p>
|
||
{:else}
|
||
<ul class="list">
|
||
{#each triggers as t (t.id)}
|
||
<li class="domain-row">
|
||
<div>
|
||
<span class="kind-badge">{t.kind}</span>
|
||
{#if t.details.kind === 'cron'}
|
||
<code>{t.details.schedule}</code>
|
||
<span class="muted">— {t.details.timezone}</span>
|
||
<span class="muted small">
|
||
last fired: {t.details.last_fired_at ?? 'never'}
|
||
</span>
|
||
{:else if t.details.kind === 'kv' || t.details.kind === 'docs'}
|
||
<code>{t.details.collection_glob}</code>
|
||
<span class="muted">— {t.details.ops.join(', ') || 'any op'}</span>
|
||
{/if}
|
||
<span class="muted small">→ {t.script_id}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="secondary danger"
|
||
onclick={() => (triggerToRemove = t)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
{:else if activeTab === 'settings' && canAdmin}
|
||
<section>
|
||
<h2>Settings</h2>
|
||
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||
<label>
|
||
<span>Name</span>
|
||
<input bind:value={editName} required />
|
||
</label>
|
||
<label>
|
||
<span>Description</span>
|
||
<input bind:value={editDescription} />
|
||
</label>
|
||
<label>
|
||
<span>Slug</span>
|
||
<input
|
||
bind:value={editSlug}
|
||
required
|
||
pattern="[a-z0-9][a-z0-9-]*"
|
||
/>
|
||
<small class="muted">
|
||
Renaming records the old slug as a permanent 301 redirect.
|
||
</small>
|
||
</label>
|
||
{#if slugTakeoverNeeded}
|
||
<div class="warning">
|
||
<strong>Slug previously redirected.</strong>
|
||
<p>
|
||
<code>{editSlug}</code> currently redirects to
|
||
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
|
||
links.
|
||
</p>
|
||
<div class="actions">
|
||
<button
|
||
type="button"
|
||
class="secondary"
|
||
onclick={() => (slugTakeoverNeeded = null)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={(e) => saveSettings(e, true)}
|
||
disabled={savingSettings}
|
||
>
|
||
{savingSettings ? 'Renaming…' : 'Rename anyway'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{:else if settingsError}
|
||
<div class="error">{settingsError}</div>
|
||
{/if}
|
||
{#if !slugTakeoverNeeded}
|
||
<div class="actions">
|
||
<button type="submit" disabled={savingSettings}>
|
||
{savingSettings ? 'Saving…' : 'Save changes'}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</form>
|
||
|
||
<div class="danger-zone">
|
||
<h3>Delete app</h3>
|
||
<p class="muted">
|
||
Permanently removes the app along with all its scripts, routes,
|
||
execution logs, and domain claims.
|
||
</p>
|
||
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if confirmingDeleteApp}
|
||
<ConfirmModal
|
||
title="Delete app “{app.name}”"
|
||
variant="danger"
|
||
confirmLabel="Delete app"
|
||
busyLabel="Deleting…"
|
||
confirmPhrase={app.slug}
|
||
confirmPhrasePrompt="Type the app slug to confirm:"
|
||
busy={deletingApp}
|
||
onConfirm={confirmDeleteApp}
|
||
onCancel={() => (confirmingDeleteApp = false)}
|
||
>
|
||
<p>
|
||
This will <strong>permanently delete</strong> everything inside
|
||
<strong>{app.name}</strong>. There is no undo.
|
||
</p>
|
||
<ul class="impact-list">
|
||
<li>
|
||
<span>Scripts</span><strong>{scripts.length}</strong>
|
||
</li>
|
||
<li>
|
||
<span>Domain claims</span><strong>{domains.length}</strong>
|
||
</li>
|
||
<li>
|
||
<span>Routes & execution logs</span><strong>all</strong>
|
||
</li>
|
||
</ul>
|
||
{#if domains.length > 0}
|
||
<p>The following hosts will stop pointing at this app:</p>
|
||
<ul class="impact-list">
|
||
{#each domains as d (d.id)}
|
||
<li>
|
||
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{#if deleteAppError}
|
||
<p class="modal-error">{deleteAppError}</p>
|
||
{/if}
|
||
</ConfirmModal>
|
||
{/if}
|
||
|
||
{#if domainToRemove}
|
||
<ConfirmModal
|
||
title="Delete domain claim"
|
||
variant="danger"
|
||
confirmLabel="Delete claim"
|
||
busyLabel="Deleting…"
|
||
busy={removingDomain}
|
||
onConfirm={confirmRemoveDomain}
|
||
onCancel={() => (domainToRemove = null)}
|
||
>
|
||
<p>
|
||
<strong>{app.name}</strong> will stop answering on
|
||
<code>{domainToRemove.pattern}</code>.
|
||
</p>
|
||
<p class="muted">
|
||
Routes already bound to this host are blocked from deletion by the
|
||
API; if so, you’ll see an error here.
|
||
</p>
|
||
{#if removeDomainError}
|
||
<p class="modal-error">{removeDomainError}</p>
|
||
{/if}
|
||
</ConfirmModal>
|
||
{/if}
|
||
|
||
{#if memberToRemove}
|
||
<ConfirmModal
|
||
title="Remove {memberToRemove.username} from {app.name}"
|
||
variant="danger"
|
||
confirmLabel="Remove member"
|
||
busyLabel="Removing…"
|
||
busy={removingMember}
|
||
onConfirm={confirmRemoveMember}
|
||
onCancel={() => (memberToRemove = null)}
|
||
>
|
||
<p>
|
||
<strong>{memberToRemove.username}</strong> will lose access to this
|
||
app. Their other app memberships and account are untouched.
|
||
</p>
|
||
{#if removeMemberError}
|
||
<p class="modal-error">{removeMemberError}</p>
|
||
{/if}
|
||
</ConfirmModal>
|
||
{/if}
|
||
|
||
{#if triggerToRemove}
|
||
<ConfirmModal
|
||
title="Delete trigger"
|
||
variant="danger"
|
||
confirmLabel="Delete trigger"
|
||
busyLabel="Deleting…"
|
||
busy={removingTrigger}
|
||
onConfirm={confirmRemoveTrigger}
|
||
onCancel={() => (triggerToRemove = null)}
|
||
>
|
||
<p>
|
||
This {triggerToRemove.kind} trigger will stop firing. The target
|
||
script is not affected.
|
||
</p>
|
||
</ConfirmModal>
|
||
{/if}
|
||
{/if}
|
||
|
||
<style>
|
||
.page-header {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.breadcrumb {
|
||
font-size: 0.875rem;
|
||
color: #64748b;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.breadcrumb a {
|
||
color: #94a3b8;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.breadcrumb a:hover {
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.breadcrumb code {
|
||
background: #1e293b;
|
||
padding: 0.1rem 0.3rem;
|
||
border-radius: 0.25rem;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 1.125rem;
|
||
margin: 0 0 1rem;
|
||
}
|
||
|
||
h3 {
|
||
font-size: 1rem;
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
border-bottom: 1px solid #1e293b;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.tabs button {
|
||
background: transparent;
|
||
color: #94a3b8;
|
||
border: none;
|
||
padding: 0.6rem 1rem;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
|
||
.tabs button:hover {
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.tabs button.active {
|
||
color: #38bdf8;
|
||
border-bottom-color: #38bdf8;
|
||
}
|
||
|
||
.tabs .tab-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
color: #94a3b8;
|
||
text-decoration: none;
|
||
padding: 0.6rem 1rem;
|
||
margin-left: auto;
|
||
border-bottom: 2px solid transparent;
|
||
font: inherit;
|
||
}
|
||
.tabs .tab-link:hover {
|
||
color: #e2e8f0;
|
||
}
|
||
.dl-badge {
|
||
display: inline-block;
|
||
min-width: 1.25rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: #ef4444;
|
||
color: #fff;
|
||
border-radius: 999px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
}
|
||
|
||
button {
|
||
background: #38bdf8;
|
||
color: #0b1220;
|
||
border: none;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 0.375rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
button.secondary {
|
||
background: transparent;
|
||
color: #94a3b8;
|
||
border: 1px solid #334155;
|
||
}
|
||
|
||
button.danger {
|
||
background: #7f1d1d;
|
||
color: #fecaca;
|
||
}
|
||
|
||
button.secondary.danger {
|
||
background: transparent;
|
||
color: #fca5a5;
|
||
border-color: #7f1d1d;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.muted {
|
||
color: #64748b;
|
||
}
|
||
|
||
.error {
|
||
border: 1px solid #b91c1c;
|
||
background: #450a0a;
|
||
color: #fecaca;
|
||
padding: 1rem;
|
||
border-radius: 0.5rem;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
.warning {
|
||
border: 1px solid #ca8a04;
|
||
background: #3f2e07;
|
||
color: #fde68a;
|
||
padding: 1rem;
|
||
border-radius: 0.5rem;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
.warning code {
|
||
background: #1e293b;
|
||
padding: 0.1rem 0.3rem;
|
||
border-radius: 0.25rem;
|
||
}
|
||
|
||
.create-form {
|
||
background: #1e293b;
|
||
border-radius: 0.5rem;
|
||
padding: 1.25rem;
|
||
margin-bottom: 1.5rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.create-form.inline {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.create-form .row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 2fr;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.create-form label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
font-size: 0.85rem;
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
.create-form label.full {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.create-form input {
|
||
background: #0b1220;
|
||
color: #e2e8f0;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.375rem;
|
||
padding: 0.5rem 0.75rem;
|
||
font: inherit;
|
||
flex: 1;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.list a {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
padding: 0.85rem 1rem;
|
||
background: #1e293b;
|
||
border-radius: 0.375rem;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
}
|
||
|
||
.list a:hover {
|
||
background: #283549;
|
||
}
|
||
|
||
.domain-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0.85rem 1rem;
|
||
background: #1e293b;
|
||
border-radius: 0.375rem;
|
||
}
|
||
|
||
.domain-row code {
|
||
background: #0b1220;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 0.25rem;
|
||
}
|
||
|
||
.primary {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.secondary {
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.danger-zone {
|
||
margin-top: 2rem;
|
||
padding: 1rem;
|
||
border: 1px solid #7f1d1d;
|
||
border-radius: 0.5rem;
|
||
background: #1e0a0a;
|
||
}
|
||
|
||
.create-form select {
|
||
background: #0b1220;
|
||
color: #e2e8f0;
|
||
border: 1px solid #334155;
|
||
border-radius: 0.375rem;
|
||
padding: 0.5rem 0.75rem;
|
||
font: inherit;
|
||
}
|
||
|
||
.create-form .row > label.grow {
|
||
grid-column: span 2;
|
||
}
|
||
|
||
.table {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.table .row {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
|
||
gap: 0.75rem;
|
||
padding: 0.85rem 1rem;
|
||
background: #1e293b;
|
||
border-radius: 0.375rem;
|
||
align-items: center;
|
||
margin: 0;
|
||
}
|
||
|
||
.table .head-row {
|
||
background: transparent;
|
||
padding: 0.25rem 1rem;
|
||
color: #64748b;
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.table .member-row.inactive {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.table .member-row strong {
|
||
margin-right: 0.4rem;
|
||
}
|
||
|
||
.table .member-row .muted {
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.table .actions-col {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.kind-badge {
|
||
display: inline-block;
|
||
padding: 0 0.45rem;
|
||
margin-left: 0.5rem;
|
||
border-radius: 0.25rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.kind-endpoint {
|
||
background: #1e3a5f;
|
||
color: #93c5fd;
|
||
}
|
||
|
||
.kind-module {
|
||
background: #3f2e7d;
|
||
color: #c4b5fd;
|
||
}
|
||
|
||
.small {
|
||
font-size: 0.85rem;
|
||
}
|
||
</style>
|