Files
dezky/apps/operator/pages/infrastructure.vue
T
Ronni Baslund 89691626f4 feat: partner enrichment, mutations, settings & branding + operator quick-wins
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.

Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.

Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
2026-05-30 08:03:07 +02:00

206 lines
6.3 KiB
Vue

<script setup lang="ts">
import { PLANNED_SERVICES } from '~/data/fixtures'
// Shape returned by /api/health/platform on platform-api.
interface ProbeResult {
id: string
name: string
role: string
status: 'ok' | 'warn' | 'bad'
latencyMs: number | null
error?: string
checkedAt: string
}
const { data: probes, pending, refresh } = await useFetch<ProbeResult[]>('/api/health/platform', {
default: () => [],
})
// Auto-refresh: every 30s while this page is mounted.
const now = ref(Date.now())
let pollTimer: ReturnType<typeof setInterval> | null = null
let clockTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
pollTimer = setInterval(() => refresh(), 30_000)
clockTimer = setInterval(() => { now.value = Date.now() }, 1_000)
})
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer)
if (clockTimer) clearInterval(clockTimer)
})
const liveCount = computed(() => (probes.value ?? []).filter((p) => p.status === 'ok').length)
const totalCount = computed(() => (probes.value ?? []).length)
const degradedCount = computed(() => (probes.value ?? []).filter((p) => p.status !== 'ok').length)
const incidentActive = computed(() => degradedCount.value > 0)
const lastCheckedAt = computed(() => {
const first = probes.value?.[0]
return first ? new Date(first.checkedAt).getTime() : null
})
const checkedAgo = computed(() => {
if (!lastCheckedAt.value) return '—'
const s = Math.max(0, Math.floor((now.value - lastCheckedAt.value) / 1000))
return `${s}s ago`
})
function tone(p: ProbeResult): 'ok' | 'warn' | 'bad' {
return p.status
}
function label(p: ProbeResult) {
return p.status === 'ok' ? 'operational' : p.status === 'warn' ? 'degraded' : 'down'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Operations"
title="Infrastructure"
:subtitle="`${liveCount} / ${totalCount} services live · checked ${checkedAgo}`"
>
<template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Refresh
</UiButton>
</template>
</PageHeader>
<div class="stage">
<div v-if="incidentActive" class="incident">
<span class="pill">
<span class="dot" />
DEGRADED
</span>
<div class="body">
<div class="title">{{ degradedCount }} service(s) reporting non-ok status</div>
<div class="sub">{{ (probes ?? []).filter((p) => p.status !== 'ok').map((p) => p.name).join(', ') }}</div>
</div>
</div>
<Eyebrow class="section-head">Live · {{ totalCount }} services</Eyebrow>
<div class="grid">
<Card v-for="p in probes" :key="p.id" :pad="0">
<div class="head">
<div>
<div class="name">{{ p.name }}</div>
<Mono dim>{{ p.role }}</Mono>
</div>
<Badge :tone="tone(p)" dot>{{ label(p) }}</Badge>
</div>
<div class="metrics">
<MetricCell label="uptime · 30d" value="—" :title="'no probe history yet'" />
<MetricCell
label="p95 latency"
:value="p.latencyMs !== null ? `${p.latencyMs}ms` : '—'"
:tone="p.latencyMs !== null && p.latencyMs > 300 ? 'warn' : undefined"
/>
<MetricCell label="error rate" value="—" :title="'no probe history yet'" />
</div>
<div class="foot">
<Mono dim>probed {{ checkedAgo }}</Mono>
<Mono v-if="p.status !== 'ok' && p.error" :class="['err', p.status]" :title="p.error">
{{ p.status === 'bad' ? 'down' : 'slow' }} · {{ p.error.slice(0, 32) }}
</Mono>
<Mono v-else dim>{{ p.status === 'ok' ? 'ok' : 'check details' }}</Mono>
</div>
</Card>
</div>
<Eyebrow class="section-head">Planned · {{ PLANNED_SERVICES.length }} services · not deployed</Eyebrow>
<div class="grid planned">
<Card v-for="s in PLANNED_SERVICES" :key="s.id" :pad="0">
<div class="head">
<div>
<div class="name">{{ s.name }}</div>
<Mono dim>{{ s.role }}</Mono>
</div>
<Badge tone="neutral" dot>not deployed</Badge>
</div>
<div class="planned-body">
<Mono dim>{{ s.note }}</Mono>
</div>
</Card>
</div>
<Mono dim class="note">
// probes live in services/platform-api/src/health/. uptime / error rate stay
em-dashed until a probe history (Prometheus, persisted event log) lands
see "Real observability" in NEXT-STEPS.md follow-ups
</Mono>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.section-head { display: block; padding: 6px 4px; }
.incident {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.3);
border-left: 3px solid var(--bad);
border-radius: 8px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: var(--bad);
color: #fff;
border-radius: 4px;
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.08em;
}
.pill .dot { width: 6px; height: 6px; border-radius: 999px; background: #fff; }
.body { flex: 1; min-width: 0; }
.title { font-size: 14px; font-weight: 600; }
.sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.grid.planned { opacity: 0.6; }
.head {
padding: 16px 18px 12px 18px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.name { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.metrics {
padding: 12px 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.foot {
padding: 10px 18px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 8px;
}
.err.bad { color: var(--bad); }
.err.warn { color: var(--warn); }
.planned-body { padding: 14px 18px; }
.note { display: block; padding: 4px 4px 0 4px; }
</style>