Files
Warp/apps/admin/src/views/FederationView.vue
T
tajniak81andClaude Opus 5.5 7e5e38c921 Admin panel (warp-admin) and the apps/ui design system
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>
2026-10-18 15:05:16 +02:00

275 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>