114b419a69
Every page header's Refresh button rendered a downward chevron because the icon set had no refresh glyph. Added a circular-arrow 'refresh' icon to UiIcon and pointed all seven Refresh buttons (Overview, Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
207 lines
6.3 KiB
Vue
207 lines
6.3 KiB
Vue
<script setup lang="ts">
|
|
import { INCIDENT, 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" />
|
|
{{ INCIDENT.severity }} · ACTIVE
|
|
</span>
|
|
<div class="body">
|
|
<div class="title">{{ INCIDENT.title }}</div>
|
|
<div class="sub">{{ degradedCount }} service(s) reporting non-ok status · IC: {{ INCIDENT.ic }}</div>
|
|
</div>
|
|
<UiButton variant="primary" disabled>Open incident</UiButton>
|
|
</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>
|