Files
Warp/apps/admin/src/views/AppServicesView.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

118 lines
4.6 KiB
Vue

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { WBadge, WBanner, WButton } from '@warp/ui'
import { listAppServices } from '@/api/admin'
import type { Namespace } from '@/api/types'
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 { useLoad } from '@/lib/useLoad'
const services = useLoad(listAppServices)
const now = ref(Date.now())
onMounted(services.load)
function reload() {
now.value = Date.now()
services.load()
}
const kinds: { key: 'users' | 'aliases' | 'rooms'; label: string }[] = [
{ key: 'users', label: 'Users' },
{ key: 'aliases', label: 'Aliases' },
{ key: 'rooms', label: 'Rooms' },
]
function list(ns: Namespace[] | undefined) {
return ns ?? []
}
</script>
<template>
<PageHeader
title="Application services"
description="Bridges and bots registered with this homeserver. Registrations are files read at startup, so this is a view, not an editor."
>
<template #actions>
<WButton icon="refresh" :loading="services.loading.value" @click="reload">Refresh</WButton>
</template>
</PageHeader>
<LoadState
:loading="services.loading.value"
:error="services.error.value"
:has-data="!!services.data.value"
@retry="services.load"
/>
<template v-if="services.data.value">
<p v-if="services.data.value.appservices.length === 0" class="wp-card p-8 text-center text-ink-muted">
No application services. Add registration files under <code class="code">app_service_api.config_files</code> and
restart.
</p>
<div class="flex flex-col gap-4">
<section v-for="s in services.data.value.appservices" :key="s.id" class="wp-card flex flex-col gap-4 p-4">
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1">
<h2 class="subheading m-0">{{ s.id }}</h2>
<Mxid :value="s.sender" class="text-ink-muted" />
</div>
<div class="flex flex-wrap gap-1">
<WBadge v-if="s.rate_limited" tone="neutral">Rate limited</WBadge>
<template v-if="s.delivery">
<WBadge v-if="s.delivery.failures === 0" tone="success" icon="shield-check">Delivering</WBadge>
<WBadge v-else tone="warning" icon="shield-alert">{{ s.delivery.failures }} failed in a row</WBadge>
</template>
<WBadge v-else tone="neutral">No URL, sent nothing</WBadge>
</div>
</div>
<WBanner v-if="s.delivery && s.delivery.failures > 0" tone="warning">
{{ s.delivery.last_error || 'Delivery is failing.' }}
<template v-if="s.delivery.retry_at_ms">Next try {{ formatRelative(s.delivery.retry_at_ms, now) }}.</template>
</WBanner>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<div v-if="s.url" class="col-span-2">
<dt class="caption text-ink-muted">URL</dt>
<dd class="m-0"><Mxid :value="s.url" /></dd>
</div>
<template v-if="s.delivery">
<div>
<dt class="caption text-ink-muted">Events delivered</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(s.delivery.events_delivered) }}</dd>
</div>
<div>
<dt class="caption text-ink-muted">Behind</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(s.delivery.behind) }} positions</dd>
</div>
<div>
<dt class="caption text-ink-muted">Last success</dt>
<dd class="m-0">{{ formatRelative(s.delivery.last_success_ms, now) }}</dd>
</div>
<div>
<dt class="caption text-ink-muted">Transaction</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(s.delivery.txn_id) }}</dd>
</div>
</template>
</dl>
<div class="grid gap-4 border-t border-line pt-4 md:grid-cols-3">
<div v-for="k in kinds" :key="k.key">
<p class="caption mb-1 text-ink-muted">{{ k.label }} namespace</p>
<ul v-if="list(s.namespaces[k.key]).length" class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="n in list(s.namespaces[k.key])" :key="n.regex" class="flex items-center gap-2">
<code class="code truncate" :title="n.regex">{{ n.regex }}</code>
<WBadge v-if="n.exclusive" tone="info">Exclusive</WBadge>
</li>
</ul>
<p v-else class="small text-ink-muted">None</p>
</div>
</div>
</section>
</div>
</template>
</template>