apps/ui: Warp's design system for Vue 3 + Tailwind v4. Tokens, fonts, icons and logo come from the brand package, with a Tailwind theme on the tokens and Vue components on the brand component styles. apps/admin: the admin panel as a separate, optional service. warp-admin embeds the built Vue app and proxies only the login endpoints and /_warp/admin/v1 to the homeserver. Pages: dashboard, users, rooms, federation, registration and tokens, server notices, application services, audit log. Also: pnpm workspace, go.work entry, Dockerfile, an `admin` compose profile, Makefile targets, a CI job with a proxy smoke test, and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
8.8 KiB
Vue
275 lines
8.8 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { WBadge, WBanner, WButton, WSwitch, WTextArea } from '@warp/ui'
|
||
import * as api from '@/api/admin'
|
||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||
import LoadState from '@/components/LoadState.vue'
|
||
import Mxid from '@/components/Mxid.vue'
|
||
import PageHeader from '@/components/PageHeader.vue'
|
||
import { formatNumber, formatRelative } from '@/lib/format'
|
||
import { useAction, useLoad } from '@/lib/useLoad'
|
||
import { useToasts } from '@/stores/toasts'
|
||
|
||
const toasts = useToasts()
|
||
|
||
const fed = useLoad(api.getFederation)
|
||
const dest = useLoad(api.getDestinations)
|
||
onMounted(() => {
|
||
fed.load()
|
||
dest.load()
|
||
})
|
||
|
||
const f = computed(() => fed.data.value)
|
||
|
||
// --- Switch ---
|
||
|
||
const toggle = useAction()
|
||
async function setEnabled(enabled: boolean) {
|
||
const ok = await toggle.run(async () => {
|
||
fed.data.value = await api.setFederation(enabled)
|
||
})
|
||
if (ok) {
|
||
toasts.success(enabled ? 'Federation is on' : 'Federation is off')
|
||
dest.load()
|
||
} else toasts.error(toggle.error.value)
|
||
}
|
||
|
||
// --- Lists ---
|
||
|
||
const allow = ref('')
|
||
const deny = ref('')
|
||
watch(
|
||
f,
|
||
(v) => {
|
||
if (!v) return
|
||
allow.value = v.allowlist.join('\n')
|
||
deny.value = v.denylist.join('\n')
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
function parse(text: string): string[] {
|
||
return [
|
||
...new Set(
|
||
text
|
||
.split(/[\s,]+/)
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
),
|
||
].sort()
|
||
}
|
||
|
||
const dirty = computed(() => {
|
||
const v = f.value
|
||
if (!v) return false
|
||
return parse(allow.value).join() !== v.allowlist.join() || parse(deny.value).join() !== v.denylist.join()
|
||
})
|
||
|
||
const lists = useAction()
|
||
async function saveLists() {
|
||
const ok = await lists.run(async () => {
|
||
fed.data.value = await api.setFederationLists(parse(allow.value), parse(deny.value))
|
||
})
|
||
if (ok) toasts.success('Lists saved. They apply to the next request.')
|
||
}
|
||
|
||
const resetOpen = ref(false)
|
||
async function resetLists() {
|
||
const ok = await lists.run(async () => {
|
||
fed.data.value = await api.resetFederationLists()
|
||
})
|
||
if (ok) {
|
||
resetOpen.value = false
|
||
toasts.success('Back to the lists in the config file')
|
||
}
|
||
}
|
||
|
||
// --- Destinations ---
|
||
|
||
const now = ref(Date.now())
|
||
const resetting = ref('')
|
||
async function resetDestination(name: string) {
|
||
resetting.value = name
|
||
try {
|
||
await api.resetDestination(name)
|
||
toasts.success(`Retrying ${name} now`)
|
||
now.value = Date.now()
|
||
dest.load()
|
||
} catch (e) {
|
||
toasts.error(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
resetting.value = ''
|
||
}
|
||
}
|
||
|
||
const sorted = computed(() =>
|
||
[...(dest.data.value?.destinations ?? [])].sort(
|
||
(a, b) => b.failures - a.failures || a.server_name.localeCompare(b.server_name),
|
||
),
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<PageHeader title="Federation" description="Whether this homeserver talks to others, and to which.">
|
||
<template #actions>
|
||
<WButton icon="refresh" :loading="fed.loading.value || dest.loading.value" @click="(fed.load(), dest.load())">
|
||
Refresh
|
||
</WButton>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<LoadState :loading="fed.loading.value" :error="fed.error.value" :has-data="!!f" @retry="fed.load" />
|
||
|
||
<template v-if="f">
|
||
<section class="wp-card mb-6 flex items-start gap-4 p-4" aria-labelledby="switch-h">
|
||
<div class="flex-1">
|
||
<h2 id="switch-h" class="subheading m-0 flex items-center gap-2">
|
||
Federation
|
||
<WBadge :tone="f.enabled ? 'success' : 'neutral'" :icon="f.enabled ? 'globe' : undefined">
|
||
{{ f.enabled ? 'On' : 'Off' }}
|
||
</WBadge>
|
||
</h2>
|
||
<p class="body mt-1 max-w-[65ch] text-ink-muted">
|
||
<template v-if="f.toggleable">
|
||
Off, this server neither sends to nor accepts from other homeservers. The change applies to the next
|
||
request, with no restart.
|
||
</template>
|
||
<template v-else>
|
||
Pinned by <code class="code">federation.allow_runtime_toggle</code> in the config file. Change it there and
|
||
restart to switch it here.
|
||
</template>
|
||
</p>
|
||
</div>
|
||
<WSwitch
|
||
:model-value="f.enabled"
|
||
label="Federation"
|
||
:disabled="!f.toggleable || toggle.busy.value"
|
||
@update:model-value="setEnabled"
|
||
/>
|
||
</section>
|
||
|
||
<section class="wp-card mb-6 flex flex-col gap-4 p-4" aria-labelledby="lists-h">
|
||
<div>
|
||
<h2 id="lists-h" class="subheading m-0 flex items-center gap-2">
|
||
Server lists
|
||
<WBadge v-if="f.lists_edited" tone="info">Edited here</WBadge>
|
||
<WBadge v-else tone="neutral">From config file</WBadge>
|
||
</h2>
|
||
<p class="body mt-1 max-w-[65ch] text-ink-muted">
|
||
One server name per line. With an allowlist, only those servers are reached; the denylist always wins.
|
||
Saved lists replace the config file’s until you reset them.
|
||
</p>
|
||
</div>
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<WTextArea
|
||
v-model="allow"
|
||
mono
|
||
label="Allowlist"
|
||
hint="Empty: every server not on the denylist"
|
||
placeholder="friend.example"
|
||
spellcheck="false"
|
||
rows="6"
|
||
/>
|
||
<WTextArea
|
||
v-model="deny"
|
||
mono
|
||
label="Denylist"
|
||
hint="These servers are never reached"
|
||
placeholder="spam.example"
|
||
spellcheck="false"
|
||
rows="6"
|
||
/>
|
||
</div>
|
||
<WBanner v-if="lists.error.value" tone="danger">{{ lists.error.value }}</WBanner>
|
||
<div class="flex flex-wrap gap-2">
|
||
<WButton variant="primary" :disabled="!dirty" :loading="lists.busy.value" @click="saveLists">Save lists</WButton>
|
||
<WButton v-if="f.lists_edited" variant="ghost" :disabled="lists.busy.value" @click="resetOpen = true">
|
||
Use config file lists
|
||
</WButton>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<section aria-labelledby="dest-h">
|
||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||
<h2 id="dest-h" class="heading m-0 flex-1">Delivery to other servers</h2>
|
||
<WBadge v-if="dest.data.value" :tone="dest.data.value.running ? 'success' : 'neutral'">
|
||
Queue {{ dest.data.value.running ? 'running' : 'stopped' }}
|
||
</WBadge>
|
||
</div>
|
||
|
||
<WBanner v-if="dest.status.value === 404" tone="info" title="No federation on this server">
|
||
This homeserver started without a signing key, so it has no federation at all.
|
||
</WBanner>
|
||
<LoadState
|
||
v-else
|
||
:loading="dest.loading.value"
|
||
:error="dest.error.value"
|
||
:has-data="!!dest.data.value"
|
||
@retry="dest.load"
|
||
/>
|
||
|
||
<div v-if="dest.data.value" class="wp-table-wrap">
|
||
<table class="wp-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Server</th>
|
||
<th>Health</th>
|
||
<th class="wp-num">Queued</th>
|
||
<th class="hidden md:table-cell">Last success</th>
|
||
<th><span class="wp-sr">Actions</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="d in sorted" :key="d.server_name">
|
||
<td><Mxid :value="d.server_name" /></td>
|
||
<td>
|
||
<WBadge v-if="d.failures === 0" tone="success" icon="shield-check">Healthy</WBadge>
|
||
<div v-else class="flex flex-col items-start gap-1">
|
||
<WBadge tone="warning" icon="shield-alert">{{ d.failures }} failed in a row</WBadge>
|
||
<span v-if="d.retry_at_ms" class="caption text-ink-muted">
|
||
Next try {{ formatRelative(d.retry_at_ms, now) }}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="wp-num">
|
||
{{ formatNumber(d.queued_pdus) }}
|
||
<span class="caption block text-ink-muted">{{ formatNumber(d.queued_edus) }} EDUs</span>
|
||
</td>
|
||
<td class="hidden text-ink-muted md:table-cell">{{ formatRelative(d.last_success_ms, now) }}</td>
|
||
<td class="text-right">
|
||
<WButton
|
||
v-if="d.failures > 0"
|
||
size="sm"
|
||
variant="ghost"
|
||
icon="refresh"
|
||
:loading="resetting === d.server_name"
|
||
@click="resetDestination(d.server_name)"
|
||
>
|
||
Retry now
|
||
</WButton>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="sorted.length === 0">
|
||
<td colspan="5" class="py-8 text-center text-ink-muted">
|
||
Nothing queued for other servers, and no failures.
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<ConfirmDialog
|
||
v-model:open="resetOpen"
|
||
title="Use config file lists"
|
||
confirm-label="Use config file lists"
|
||
:danger="false"
|
||
:busy="lists.busy.value"
|
||
:error="lists.error.value"
|
||
@confirm="resetLists"
|
||
>
|
||
<p>The lists saved here are forgotten, and the allowlist and denylist from the config file apply again.</p>
|
||
</ConfirmDialog>
|
||
</template>
|