From 5039aedca59a304f5caaf539c3327b94afeb11db Mon Sep 17 00:00:00 2001
From: Benjamin
{{ form.url }}
-{{ cve.fix_command }}
- This token will never be shown again. - Copy the install command before closing. -
-Local address detected
-
- The server URL resolves to a local address. Remote agents won't be able to connect.
- Set MAINTENANT_GRPC_URL
- to a publicly reachable address.
-
Token
-{{ token.token }}
- Install command
- - -{{ currentTemplate }}
-
- - Expires: {{ new Date(token.expires_at).toLocaleString() }} -
- - -+ This token will never be shown again. + Copy the install command before closing. +
+ +MAINTENANT_GRPC_URL
+ to a publicly reachable address.
+ Token
+{{ token.token }}
+ Install command
+ +{{ currentTemplate }}
+
+ + Expires: {{ new Date(token.expires_at).toLocaleString() }} +
+ You're monitoring + {{ used }} of {{ limit }} + hosts on this server. +
++ Need to monitor more hosts? Get in touch directly and I'll help you set it up. +
+- You're monitoring - {{ used }} of {{ limit }} - hosts on this server. -
-
- The
-
- Need to monitor more hosts? Get in touch directly and I'll help you set it up. -
- - -container:42) or by tags.
-
Rollback Command
-
-
{{ copiedRollback ? 'Copied!' : 'Copy' }}
-
+
-
-
{{ detail.pinned ? 'Unpin this version' : 'Pin this version' }}
-
+
-
-
+
+
Confirm pin
-
+
diff --git a/frontend/src/components/WebhookForm.vue b/frontend/src/components/WebhookForm.vue
index f0a53b95..752270f2 100644
--- a/frontend/src/components/WebhookForm.vue
+++ b/frontend/src/components/WebhookForm.vue
@@ -12,192 +12,91 @@
-->
-
-
-
-
-
-
-
- Add Webhook
-
-
-
+
+
-
+
+
+ {{ error }}
+
+
+
+
+ Cancel
+
+ {{ submitting ? 'Creating...' : 'Add Webhook' }}
+
+
+
diff --git a/frontend/src/components/dashboard/IncidentBanner.vue b/frontend/src/components/dashboard/IncidentBanner.vue
index 3caf3c5e..05f8290d 100644
--- a/frontend/src/components/dashboard/IncidentBanner.vue
+++ b/frontend/src/components/dashboard/IncidentBanner.vue
@@ -14,6 +14,7 @@
@@ -64,48 +67,42 @@ function toggleChannel(id: string) {
Level {{ index + 1 }}
-
-
-
+ />
-
-
-
-
-
-
- {{ preset.label }}
-
+
+
+
+ setDelay(Number(v))"
+ />
+ setDelay(Number(v))"
+ />
-
-
+
+
@@ -113,22 +110,15 @@ function toggleChannel(id: string) {
No channels available.
-
-
+
-
-
- {{ ch.name }}
-
+ :value="ch.id"
+ :model-value="modelValue.channel_ids"
+ :label="ch.name"
+ @update:model-value="(v) => setChannelIds(v as (string | number)[])"
+ />
diff --git a/frontend/src/components/escalation/PolicyEditor.vue b/frontend/src/components/escalation/PolicyEditor.vue
index 488e460d..1bb4ee09 100644
--- a/frontend/src/components/escalation/PolicyEditor.vue
+++ b/frontend/src/components/escalation/PolicyEditor.vue
@@ -22,6 +22,11 @@ import { X, Plus, Loader2, ArrowRight, Shield } from 'lucide-vue-next'
import LevelEditor from './LevelEditor.vue'
import OverlapWarningComponent from './OverlapWarning.vue'
import InlineAlert from '@/components/ui/InlineAlert.vue'
+import UiButton from '@/components/ui/UiButton.vue'
+import FormField from '@/components/ui/FormField.vue'
+import TextInput from '@/components/ui/TextInput.vue'
+import CheckboxInput from '@/components/ui/CheckboxInput.vue'
+import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useEscalationApi } from '@/composables/useEscalationApi'
interface Channel {
@@ -95,14 +100,6 @@ watch([name, severities, levels], () => checkOverlap(), { deep: true })
const SEVERITY_OPTIONS = ['warning', 'critical']
-function toggleSeverity(sev: string) {
- if (severities.value.includes(sev)) {
- severities.value = severities.value.filter((s) => s !== sev)
- } else {
- severities.value = [...severities.value, sev]
- }
-}
-
function addLevel() {
if (levels.value.length >= maxLevels.value) return
const last = levels.value[levels.value.length - 1]
@@ -189,12 +186,7 @@ onMounted(() => {
{{ policy ? 'Edit policy' : 'New escalation policy' }}
-
-
-
+
@@ -207,17 +199,11 @@ onMounted(() => {
-
-
-
-
+
+
+
+
+
@@ -227,16 +213,7 @@ onMounted(() => {
Inactive policies are saved but never triggered.
-
-
-
+
@@ -244,22 +221,14 @@ onMounted(() => {
-
-
+
- {{ sev.charAt(0).toUpperCase() + sev.slice(1) }}
-
+ v-model="severities"
+ :value="sev"
+ :label="sev.charAt(0).toUpperCase() + sev.slice(1)"
+ />
@@ -330,14 +299,16 @@ onMounted(() => {
@remove="removeLevel(i)"
/>
-
-
Add level
-
+
@@ -346,20 +317,10 @@ onMounted(() => {
-
- Cancel
-
-
-
+ Cancel
+
{{ saving ? 'Saving...' : 'Save policy' }}
-
+
diff --git a/frontend/src/components/escalation/PolicyList.vue b/frontend/src/components/escalation/PolicyList.vue
index 8f594a9a..405dd619 100644
--- a/frontend/src/components/escalation/PolicyList.vue
+++ b/frontend/src/components/escalation/PolicyList.vue
@@ -16,6 +16,7 @@ import { useConfirm } from '@/composables/useConfirm'
import type { EscalationPolicy } from '@/types/escalation'
import { timeAgo } from '@/utils/time'
import { Pencil, Trash2, Layers, CheckCircle2, CircleDashed } from 'lucide-vue-next'
+import UiButton from '@/components/ui/UiButton.vue'
defineProps<{
policies: EscalationPolicy[]
@@ -80,12 +81,9 @@ function severityLabel(severities: string[]): string {
No escalation policies yet
Create a policy to start routing alerts through escalation chains.
-
+
Create first policy
-
+
@@ -131,20 +129,24 @@ function severityLabel(severities: string[]): string {
{{ timeAgo(policy.updated_at) }}
-
-
-
-
+
-
-
+ />
diff --git a/frontend/src/components/personalization/BrandingSection.vue b/frontend/src/components/personalization/BrandingSection.vue
index 1763443c..e468abdb 100644
--- a/frontend/src/components/personalization/BrandingSection.vue
+++ b/frontend/src/components/personalization/BrandingSection.vue
@@ -3,6 +3,9 @@ import { ref, computed } from 'vue'
import { Upload, Trash2, X } from 'lucide-vue-next'
import { personalizationApi } from '@/services/personalizationApi'
import { usePersonalizationStore } from '@/stores/personalization'
+import FormField from '@/components/ui/FormField.vue'
+import TextInput from '@/components/ui/TextInput.vue'
+import UiButton from '@/components/ui/UiButton.vue'
type AssetRole = 'logo' | 'favicon' | 'hero'
@@ -122,37 +125,17 @@ defineExpose({ flushPendingAssets })
-
-
-
- 1–100 characters
-
+
+
+
+
+
-
-
-
- 0–200 characters
-
+
+
+
+
+
@@ -169,15 +152,9 @@ defineExpose({ flushPendingAssets })
class="sr-only"
@change="(e) => onFileSelected('logo', e)"
/>
-
-
+
Choose file…
-
+
{{ logoFile.name }}
-
-
+
Clear
-
-
+
-
Remove
-
+
Will be removed on save
-
- Undo
-
+ Undo
-
+
@@ -245,15 +202,9 @@ defineExpose({ flushPendingAssets })
class="sr-only"
@change="(e) => onFileSelected('favicon', e)"
/>
-
-
+
Choose file…
-
+
{{ faviconFile.name }}
-
-
+
Clear
-
-
+
-
Remove
-
+
Will be removed on save
-
- Undo
-
+ Undo
@@ -313,15 +251,9 @@ defineExpose({ flushPendingAssets })
class="sr-only"
@change="(e) => onFileSelected('hero', e)"
/>
-
-
+
Choose file…
-
+
{{ heroFile.name }}
-
-
+
Clear
-
-
+
-
Remove
-
+
Will be removed on save
-
- Undo
-
+ Undo
-
+
{{ logoPreviewUrl }} {{ faviconPreviewUrl }} {{ heroPreviewUrl }}
diff --git a/frontend/src/components/personalization/ColorPaletteSection.vue b/frontend/src/components/personalization/ColorPaletteSection.vue
index 42c7d7b7..0aa2758c 100644
--- a/frontend/src/components/personalization/ColorPaletteSection.vue
+++ b/frontend/src/components/personalization/ColorPaletteSection.vue
@@ -1,5 +1,7 @@
Localization
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
IANA timezone identifier, e.g. America/New_York
-
-
-
-
-
+ Date Format
+
diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue
index 7bb71630..30dc0db6 100644
--- a/frontend/src/layouts/DefaultLayout.vue
+++ b/frontend/src/layouts/DefaultLayout.vue
@@ -13,6 +13,7 @@ import AlertBanner from '@/components/ui/AlertBanner.vue'
import DetailSlideOver from '@/components/DetailSlideOver.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import ToastContainer from '@/components/ToastContainer.vue'
+import UiButton from '@/components/ui/UiButton.vue'
import { useAppVersion } from '@/composables/useAppVersion'
import { useStorageStore } from '@/stores/storage'
import {
@@ -254,13 +255,15 @@ watch(
>{{ version }}
-
Compare editions
-
+
diff --git a/frontend/src/pages/AgentsPage.vue b/frontend/src/pages/AgentsPage.vue
index 88838d7d..9d1a9e22 100644
--- a/frontend/src/pages/AgentsPage.vue
+++ b/frontend/src/pages/AgentsPage.vue
@@ -23,6 +23,7 @@ import HostLimitDialog from '@/components/HostLimitDialog.vue'
import AgentDetailPanel from '@/components/AgentDetailPanel.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import UiTooltip from '@/components/ui/UiTooltip.vue'
+import UiButton from '@/components/ui/UiButton.vue'
import { osNeedsAttention, osSupportHint, osSupportLabel, osSupportSeverity } from '@/utils/osSupport'
import { docUrl } from '@/utils/docs'
import { MonitorDot, Server, Boxes, ShieldCheck } from 'lucide-vue-next'
@@ -175,14 +176,9 @@ function runtimeLabel(rt: string): string {
:style="{ color: hostQuota.isAtLimit ? 'var(--mnt-status-warn-text)' : 'var(--mnt-text-muted)' }"
>{{ hostQuota.used }}/{{ hostQuota.limit }}
-
+
{{ generatingToken ? 'Generating…' : 'Generate enrollment token' }}
-
+
{{ tokenError }}
@@ -294,13 +290,7 @@ function runtimeLabel(rt: string): string {
{{ tok.token_masked }}
{{ formatDate(tok.expires_at) }}
-
- Revoke
-
+ Revoke
@@ -379,7 +369,4 @@ function runtimeLabel(rt: string): string {
.agent-row:hover {
background-color: var(--mnt-bg-hover);
}
-.revoke-btn:hover {
- background-color: var(--mnt-status-down-bg);
-}
diff --git a/frontend/src/pages/AlertsPage.vue b/frontend/src/pages/AlertsPage.vue
index eb859f34..79bc28c3 100644
--- a/frontend/src/pages/AlertsPage.vue
+++ b/frontend/src/pages/AlertsPage.vue
@@ -21,6 +21,7 @@ import AlertList from '@/components/AlertList.vue'
import TriggerManager from '@/components/TriggerManager.vue'
import SilenceRuleManager from '@/components/SilenceRuleManager.vue'
import FeatureHint from '@/components/ui/FeatureHint.vue'
+import TabNav, { type TabNavItem } from '@/components/ui/TabNav.vue'
import { docUrl } from '@/utils/docs'
type Tab = 'history' | 'triggers' | 'silence'
@@ -40,6 +41,12 @@ const activeTab = computed({
set: (tab: Tab) => router.replace({ name: 'alerts', params: { tab } }),
})
+const tabItems = computed(() => [
+ { value: 'history', label: 'History' },
+ { value: 'triggers', label: 'Triggers', count: triggersStore.triggers.length || undefined },
+ { value: 'silence', label: 'Silence Rules', count: store.activeSilenceCount || undefined, countTone: 'warn' },
+])
+
onMounted(() => {
store.fetchAlerts()
store.fetchActiveAlerts()
@@ -73,53 +80,8 @@ onUnmounted(() => {
-
-
+
+
diff --git a/frontend/src/pages/CertificatesPage.vue b/frontend/src/pages/CertificatesPage.vue
index c0e0f005..306ffb1c 100644
--- a/frontend/src/pages/CertificatesPage.vue
+++ b/frontend/src/pages/CertificatesPage.vue
@@ -35,6 +35,12 @@ import { timeAgo } from '@/utils/time'
import { ShieldCheck } from 'lucide-vue-next'
import { docUrl } from '@/utils/docs'
import QuotaRefusal from '@/components/QuotaRefusal.vue'
+import CountBadge from '@/components/ui/CountBadge.vue'
+import UiButton from '@/components/ui/UiButton.vue'
+import FormField from '@/components/ui/FormField.vue'
+import TextInput from '@/components/ui/TextInput.vue'
+import SelectInput from '@/components/ui/SelectInput.vue'
+import SegmentedToggle from '@/components/ui/SegmentedToggle.vue'
const store = useCertificatesStore()
const containers = useContainersStore()
@@ -221,16 +227,11 @@ function handleSelect(id: string) {
-
- {{ quota.used }}/{{ quota.limit }}
-
+ :value="`${quota.used}/${quota.limit}`"
+ :tone="quota.isAtLimit ? 'danger' : quota.nearLimit ? 'warn' : 'neutral'"
+ />
Upgrade
-
{{ showCreateForm ? 'Cancel' : 'New Monitor' }}
-
+
@@ -284,109 +275,60 @@ function handleSelect(id: string) {
@@ -405,28 +347,37 @@ function handleSelect(id: string) {
@@ -453,12 +404,9 @@ function handleSelect(id: string) {
description="Try a different search term, or clear the filters to see every monitored certificate."
>
-
+
Clear filters
-
+
@@ -526,16 +474,3 @@ function handleSelect(id: string) {
-
-
diff --git a/frontend/src/pages/DesignSystemPage.vue b/frontend/src/pages/DesignSystemPage.vue
index b4169c07..325f2aa6 100644
--- a/frontend/src/pages/DesignSystemPage.vue
+++ b/frontend/src/pages/DesignSystemPage.vue
@@ -195,14 +195,9 @@ const demoModalOpen = ref(false)
/_ds
-
-
+
{{ resolvedTheme === 'dark' ? 'Light' : 'Dark' }}
-
+
diff --git a/frontend/src/pages/EndpointsPage.vue b/frontend/src/pages/EndpointsPage.vue
index 0efe6ec7..dc0e431e 100644
--- a/frontend/src/pages/EndpointsPage.vue
+++ b/frontend/src/pages/EndpointsPage.vue
@@ -35,6 +35,12 @@ import { endpointTone } from '@/utils/endpointTone'
import { timeAgo } from '@/utils/time'
import { docUrl } from '@/utils/docs'
import QuotaRefusal from '@/components/QuotaRefusal.vue'
+import CountBadge from '@/components/ui/CountBadge.vue'
+import UiButton from '@/components/ui/UiButton.vue'
+import FormField from '@/components/ui/FormField.vue'
+import TextInput from '@/components/ui/TextInput.vue'
+import SelectInput from '@/components/ui/SelectInput.vue'
+import SegmentedToggle from '@/components/ui/SegmentedToggle.vue'
const store = useEndpointsStore()
const containers = useContainersStore()
@@ -60,6 +66,14 @@ function onRetiredChange() {
store.fetchEndpoints()
}
+const retiredFilter = computed({
+ get: () => (store.showRetired ? 'true' : 'false'),
+ set: (v) => {
+ store.showRetired = v === 'true'
+ onRetiredChange()
+ },
+})
+
const columns: Column[] = [
{ key: 'target', label: 'Target', sortable: true },
{ key: 'type', label: 'Type', width: '68px', priority: 'sm' },
@@ -168,16 +182,11 @@ onUnmounted(() => {
-
- {{ quota.used }}/{{ quota.limit }}
-
+ :value="`${quota.used}/${quota.limit}`"
+ :tone="quota.isAtLimit ? 'danger' : quota.nearLimit ? 'warn' : 'neutral'"
+ />
{
>
Upgrade
-
{{ showCreateForm ? 'Cancel' : 'New Endpoint' }}
-
+
@@ -235,116 +234,53 @@ onUnmounted(() => {
@@ -383,39 +319,39 @@ onUnmounted(() => {
@@ -490,13 +426,9 @@ onUnmounted(() => {
description="Try a different search term, or clear the filters to see every endpoint again."
>
-
+
Clear filters
-
+
diff --git a/frontend/src/pages/EscalationPage.vue b/frontend/src/pages/EscalationPage.vue
index 156a4b26..fc351e7e 100644
--- a/frontend/src/pages/EscalationPage.vue
+++ b/frontend/src/pages/EscalationPage.vue
@@ -20,6 +20,7 @@ import PolicyEditor from '@/components/escalation/PolicyEditor.vue'
import { useEscalationStore } from '@/stores/escalation'
import type { EscalationPolicy } from '@/types/escalation'
import UnlockCta from '@/components/UnlockCta.vue'
+import UiButton from '@/components/ui/UiButton.vue'
import { Plus, BellRing, CheckCircle2, ShieldAlert } from 'lucide-vue-next'
const store = useEscalationStore()
@@ -94,14 +95,9 @@ onMounted(() => {
{{ store.policies.length }} {{ store.policies.length === 1 ? 'policy' : 'policies' }}
-
-
+
New policy
-
+
diff --git a/frontend/src/pages/HeartbeatsPage.vue b/frontend/src/pages/HeartbeatsPage.vue
index 4839d100..1c077152 100644
--- a/frontend/src/pages/HeartbeatsPage.vue
+++ b/frontend/src/pages/HeartbeatsPage.vue
@@ -34,6 +34,13 @@ import { timeAgo } from '@/utils/time'
import { Heart } from 'lucide-vue-next'
import { docUrl } from '@/utils/docs'
import QuotaRefusal from '@/components/QuotaRefusal.vue'
+import CountBadge from '@/components/ui/CountBadge.vue'
+import UiButton from '@/components/ui/UiButton.vue'
+import FormField from '@/components/ui/FormField.vue'
+import TextInput from '@/components/ui/TextInput.vue'
+import SelectInput from '@/components/ui/SelectInput.vue'
+import SegmentedToggle from '@/components/ui/SegmentedToggle.vue'
+import CheckboxInput from '@/components/ui/CheckboxInput.vue'
const { getQuota, reload } = useEdition()
@@ -185,16 +192,11 @@ async function handleCreate() {
-
- {{ quota.used }}/{{ quota.limit }}
-
+ :value="`${quota.used}/${quota.limit}`"
+ :tone="quota.isAtLimit ? 'danger' : quota.nearLimit ? 'warn' : 'neutral'"
+ />
Upgrade
-
{{ showCreateForm ? 'Cancel' : 'New Heartbeat' }}
-
+
@@ -251,83 +243,44 @@ async function handleCreate() {
Create Heartbeat Monitor
@@ -346,18 +299,21 @@ async function handleCreate() {
-
+
+
+
@@ -375,13 +331,9 @@ async function handleCreate() {
description="Heartbeat monitors track cron jobs and scheduled tasks. Create one and integrate the ping URL into your scripts."
>
-
+
Create your first heartbeat
-
+
@@ -393,12 +345,9 @@ async function handleCreate() {
description="Try a different search term, or clear the filters to see every heartbeat monitor."
>
-
+
Clear filters
-
+
@@ -460,16 +409,3 @@ async function handleCreate() {
-
-
diff --git a/frontend/src/pages/StatusAdminPage.vue b/frontend/src/pages/StatusAdminPage.vue
index fd44b46e..a8e7cb5c 100644
--- a/frontend/src/pages/StatusAdminPage.vue
+++ b/frontend/src/pages/StatusAdminPage.vue
@@ -29,6 +29,8 @@ import FooterSection from '@/components/personalization/FooterSection.vue'
import FaqSection from '@/components/personalization/FaqSection.vue'
import LocalizationSection from '@/components/personalization/LocalizationSection.vue'
import type { PalettePayload } from '@/services/personalizationApi'
+import TabNav, { type TabNavItem } from '@/components/ui/TabNav.vue'
+import UiButton from '@/components/ui/UiButton.vue'
const { hasFeature, editionPermits, statusURL } = useEdition()
@@ -137,6 +139,15 @@ onMounted(() => {
onUnmounted(() => {
store.disconnectSSE()
})
+
+const tabItems = computed(() => [
+ { value: 'components', label: 'Components', count: store.components?.length || undefined },
+ { value: 'incidents', label: 'Incidents', count: store.incidentsTotal || undefined },
+ { value: 'maintenance', label: 'Maintenance', count: store.maintenance?.length || undefined },
+ { value: 'subscribers', label: 'Subscribers', count: store.subscriberTotal || undefined },
+ { value: 'smtp', label: 'SMTP' },
+ { value: 'personalization', label: 'Personalization' },
+])
@@ -160,43 +171,13 @@ onUnmounted(() => {
-
-
-
+ (activeTab = v as Tab)"
+ />
@@ -267,14 +248,9 @@ onUnmounted(() => {
Saved!
{{ persoSaveError }}
-
+
{{ persoSaving ? 'Saving…' : 'Save changes' }}
-
+
diff --git a/frontend/src/pages/TasksPage.vue b/frontend/src/pages/TasksPage.vue
index 240099e2..3d74cff2 100644
--- a/frontend/src/pages/TasksPage.vue
+++ b/frontend/src/pages/TasksPage.vue
@@ -24,6 +24,7 @@ import { useTopologyRefetch } from '@/composables/useTopologyRefetch'
import { timeAgo } from '@/utils/time'
import { ClipboardList } from 'lucide-vue-next'
import HostBadge from '@/components/HostBadge.vue'
+import SelectInput, { type SelectOption } from '@/components/ui/SelectInput.vue'
type TaskWithService = SwarmTaskResponse & { service_name: string }
@@ -69,6 +70,16 @@ const filteredTasks = computed(() => {
})
})
+const serviceOptions = computed(() => [
+ { value: '', label: 'All services' },
+ ...services.value.map((svc) => ({ value: svc.name, label: svc.name })),
+])
+
+const stateOptions: SelectOption[] = [
+ { value: '', label: 'All states' },
+ ...TASK_STATES.map((state) => ({ value: state, label: state })),
+]
+
onMounted(loadTasks)
// Refresh when the host scope changes, or a matching remote agent reports.
@@ -134,30 +145,14 @@ function shortId(id: string): string {
-
+
-
+
diff --git a/frontend/src/pages/UpdatesPage.vue b/frontend/src/pages/UpdatesPage.vue
index 78db850a..1cf481ed 100644
--- a/frontend/src/pages/UpdatesPage.vue
+++ b/frontend/src/pages/UpdatesPage.vue
@@ -37,6 +37,7 @@ import {
osTotal,
} from '@/utils/osSupport'
import { docUrl } from '@/utils/docs'
+import UiButton from '@/components/ui/UiButton.vue'
import {
RefreshCw,
AlertTriangle,
@@ -167,14 +168,16 @@ onUnmounted(() => {
Last scan: {{ formatTime(updates.summary.last_scan) }}
-
-
{{ updates.scanning ? 'Scanning...' : 'Check now' }}
-
+
diff --git a/frontend/src/pages/WebhooksPage.vue b/frontend/src/pages/WebhooksPage.vue
index 5bd4e4e8..1c8f81b0 100644
--- a/frontend/src/pages/WebhooksPage.vue
+++ b/frontend/src/pages/WebhooksPage.vue
@@ -16,13 +16,7 @@
Webhooks
-
- + Add Webhook
-
+ Add Webhook
Never
-
{{ testing === wh.id ? 'Testing...' : 'Test' }}
-
-
+
+
Delete
-
+
@@ -143,10 +134,12 @@
-
-
+
+
+
+ {{ label }}
+
+
+
+
+
+
-
- {{ label }}
- All namespaces
+
-
-
-
-
-
-
+
+
+
+
+
+
+
- All namespaces
+ {{ ns }}
-
-
-
-
-
-
-
-
- {{ ns }}
-
-
-
+
+
-
-
- No namespaces found
-
+
+
+ No namespaces found
-
+
diff --git a/frontend/src/components/ResourceAlertConfig.vue b/frontend/src/components/ResourceAlertConfig.vue
index 59e34086..6adf8ecd 100644
--- a/frontend/src/components/ResourceAlertConfig.vue
+++ b/frontend/src/components/ResourceAlertConfig.vue
@@ -12,7 +12,7 @@
-->
@@ -206,37 +218,12 @@ onMounted(() => fetchHistory())
Time Range:
-
-
- {{ w.window }}
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
- {{ capitalize(runtimeContext) }}
-
-
-
-
-
+
+
-
-
- Runtime Context
-
+
+
+
+
+
+
+
+
+
+ {{ capitalize(runtimeContext) }}
+
+
+
+
+
+ Runtime Context
+
+
+
+
+
+
+ Runtime
+ {{ capitalize(store.runtime) }}
+
+
+
+
+ Context
+ {{ capitalize(runtimeContext) }}
+
+
+
+
+ Status
+ {{ connected ? 'Connected' : 'Disconnected' }}
+
+
+
+
+ Detected at
+ {{ formatDetectedAt(store.detectedAt) }}
+
-
-
-
+
+
+
- Runtime
- {{ capitalize(store.runtime) }}
+ Cluster ID
+ {{ (store.metadata as { cluster_id: string }).cluster_id.slice(0, 12) }}
-
-
- Context
- {{ capitalize(runtimeContext) }}
+ Managers
+ {{ (store.metadata as { manager_count: number }).manager_count }}
-
-
- Status
- {{ connected ? 'Connected' : 'Disconnected' }}
+ Workers
+ {{ (store.metadata as { worker_count: number }).worker_count }}
+
+
-
+
+
+
- Detected at
- {{ formatDetectedAt(store.detectedAt) }}
+ Namespaces
+ {{ (store.metadata as { namespace_count: number }).namespace_count }}
+
+
+ Nodes
+ {{ (store.metadata as { node_count: number }).node_count }}
-
-
-
-
-
- Cluster ID
- {{ (store.metadata as { cluster_id: string }).cluster_id.slice(0, 12) }}
-
-
- Managers
- {{ (store.metadata as { manager_count: number }).manager_count }}
-
-
- Workers
- {{ (store.metadata as { worker_count: number }).worker_count }}
-
-
-
-
-
-
-
-
- Namespaces
- {{ (store.metadata as { namespace_count: number }).namespace_count }}
-
-
- Nodes
- {{ (store.metadata as { node_count: number }).node_count }}
-
-
-
-
-
-
+
+
+
diff --git a/frontend/src/components/SilenceRuleManager.vue b/frontend/src/components/SilenceRuleManager.vue
index 26aed599..0bb673d6 100644
--- a/frontend/src/components/SilenceRuleManager.vue
+++ b/frontend/src/components/SilenceRuleManager.vue
@@ -182,7 +182,7 @@ function formatDuration(seconds: number): string {
{{ formatTime(rule.starts_at) }} - {{ formatTime(rule.expires_at) }}
- Cancel
+ Cancel
{{ rule.cancelled_at ? 'Cancelled' : 'Expired' }}
diff --git a/frontend/src/components/StatusComponentManager.vue b/frontend/src/components/StatusComponentManager.vue
index 093d30fb..92bf2976 100644
--- a/frontend/src/components/StatusComponentManager.vue
+++ b/frontend/src/components/StatusComponentManager.vue
@@ -36,6 +36,7 @@ import UiButton from '@/components/ui/UiButton.vue'
import SegmentedToggle from '@/components/ui/SegmentedToggle.vue'
import TabNav, { type TabNavItem } from '@/components/ui/TabNav.vue'
import SearchInput from '@/components/ui/SearchInput.vue'
+import ChipToggle from '@/components/ui/ChipToggle.vue'
const store = useStatusAdminStore()
const { getQuota, reload } = useEdition()
@@ -367,13 +368,12 @@ function componentSummary(c: StatusComponent): string {
(compositionMode = v as 'explicit' | 'match-all')"
/>
@@ -382,23 +382,16 @@ function componentSummary(c: StatusComponent): string {
-
- {{ m.type[0] }}
+ {{ m.type[0] }}
{{ m.name }}
-
- ×
-
-
+
No monitors selected. Pick at least one below.
@@ -517,7 +510,7 @@ function componentSummary(c: StatusComponent): string {
@change="handleOverride(c, ($event.target as HTMLSelectElement).value)"
/>
Edit
- Delete
+ Delete
diff --git a/frontend/src/components/StatusIncidentManager.vue b/frontend/src/components/StatusIncidentManager.vue
index 8a0ac257..8d93b3b2 100644
--- a/frontend/src/components/StatusIncidentManager.vue
+++ b/frontend/src/components/StatusIncidentManager.vue
@@ -193,7 +193,7 @@ const incidentStatusOptions = ['investigating', 'identified', 'monitoring', 'res
Post Update
- Delete
+ Delete
diff --git a/frontend/src/components/StatusMaintenanceManager.vue b/frontend/src/components/StatusMaintenanceManager.vue
index 8aa68991..89cb3f5c 100644
--- a/frontend/src/components/StatusMaintenanceManager.vue
+++ b/frontend/src/components/StatusMaintenanceManager.vue
@@ -186,7 +186,7 @@ function statusStyle(mw: MaintenanceWindow): { bg: string; color: string } {
>
Edit
- Delete
+ Delete
{{ mw.description }}
diff --git a/frontend/src/components/StatusSmtpConfig.vue b/frontend/src/components/StatusSmtpConfig.vue
index 6397c528..3f8d651d 100644
--- a/frontend/src/components/StatusSmtpConfig.vue
+++ b/frontend/src/components/StatusSmtpConfig.vue
@@ -133,6 +133,7 @@ function onPasswordInput() {
:id="id"
v-model="form.password"
type="password"
+ autocomplete="new-password"
:aria-describedby="describedBy"
:invalid="invalid"
:placeholder="form.password_set ? 'Password configured' : ''"
diff --git a/frontend/src/components/TopConsumersWidget.vue b/frontend/src/components/TopConsumersWidget.vue
index b3bc778f..3bd474a6 100644
--- a/frontend/src/components/TopConsumersWidget.vue
+++ b/frontend/src/components/TopConsumersWidget.vue
@@ -12,7 +12,7 @@
-->
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/ColorInput.vue b/frontend/src/components/ui/ColorInput.vue
new file mode 100644
index 00000000..d67e53b7
--- /dev/null
+++ b/frontend/src/components/ui/ColorInput.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ (model = (e.target as HTMLInputElement).value)"
+ />
+
+
diff --git a/frontend/src/components/ui/DisclosureButton.vue b/frontend/src/components/ui/DisclosureButton.vue
new file mode 100644
index 00000000..e0561700
--- /dev/null
+++ b/frontend/src/components/ui/DisclosureButton.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/FileInput.vue b/frontend/src/components/ui/FileInput.vue
new file mode 100644
index 00000000..cbaefe56
--- /dev/null
+++ b/frontend/src/components/ui/FileInput.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+ {{ buttonLabel }}
+
+
+ {{ file.name }}
+
+
+ Clear
+
+
+
diff --git a/frontend/src/components/ui/OptionCards.vue b/frontend/src/components/ui/OptionCards.vue
new file mode 100644
index 00000000..4cd0fce3
--- /dev/null
+++ b/frontend/src/components/ui/OptionCards.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/PopoverMenu.vue b/frontend/src/components/ui/PopoverMenu.vue
new file mode 100644
index 00000000..f9e47eb2
--- /dev/null
+++ b/frontend/src/components/ui/PopoverMenu.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/RadioGroup.vue b/frontend/src/components/ui/RadioGroup.vue
index 32368b02..28154848 100644
--- a/frontend/src/components/ui/RadioGroup.vue
+++ b/frontend/src/components/ui/RadioGroup.vue
@@ -11,18 +11,18 @@
Source: https://github.com/kolapsis/maintenant
-->
-
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/SegmentedToggle.vue b/frontend/src/components/ui/SegmentedToggle.vue
index 06283bf3..f34fddf2 100644
--- a/frontend/src/components/ui/SegmentedToggle.vue
+++ b/frontend/src/components/ui/SegmentedToggle.vue
@@ -3,27 +3,30 @@
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
or a commercial license. See COMMERCIAL-LICENSE.md.
-->
-
@@ -36,9 +39,12 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
v-for="opt in options"
:key="opt.value"
type="button"
+ :data-test="opt.dataTest"
+ :data-locked="opt.locked ? 'true' : undefined"
+ :disabled="opt.disabled"
:aria-pressed="modelValue === opt.value"
:title="opt.title ?? opt.label"
- class="focus-ring inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition-colors"
+ class="focus-ring inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="
modelValue === opt.value
? 'text-mnt-accent'
diff --git a/frontend/src/components/ui/SelectInput.vue b/frontend/src/components/ui/SelectInput.vue
index 251f5a5f..ebb73e79 100644
--- a/frontend/src/components/ui/SelectInput.vue
+++ b/frontend/src/components/ui/SelectInput.vue
@@ -11,25 +11,25 @@
Source: https://github.com/kolapsis/maintenant
-->
-
diff --git a/frontend/src/components/ui/TabNav.vue b/frontend/src/components/ui/TabNav.vue
index 034775f1..9d9aea71 100644
--- a/frontend/src/components/ui/TabNav.vue
+++ b/frontend/src/components/ui/TabNav.vue
@@ -11,23 +11,23 @@
Source: https://github.com/kolapsis/maintenant
-->
-
diff --git a/frontend/src/components/ui/UiButton.vue b/frontend/src/components/ui/UiButton.vue
index cdcf6026..049dcf07 100644
--- a/frontend/src/components/ui/UiButton.vue
+++ b/frontend/src/components/ui/UiButton.vue
@@ -15,7 +15,7 @@
import type { Component } from 'vue'
import { Loader2 } from 'lucide-vue-next'
-type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'
+type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'danger-ghost'
withDefaults(
defineProps<{
@@ -25,6 +25,8 @@ withDefaults(
loading?: boolean
disabled?: boolean
icon?: Component
+ block?: boolean
+ align?: 'center' | 'start' | 'between'
}>(),
{
variant: 'secondary',
@@ -33,14 +35,23 @@ withDefaults(
loading: false,
disabled: false,
icon: undefined,
+ block: false,
+ align: 'center',
},
)
+const justifyClasses: Record<'center' | 'start' | 'between', string> = {
+ center: 'justify-center',
+ start: 'justify-start',
+ between: 'justify-between',
+}
+
const variantClasses: Record = {
primary: 'bg-[var(--mnt-accent)] text-mnt-inverted hover:bg-[var(--mnt-accent-hover)]',
secondary: 'border border-mnt-default text-mnt-secondary hover:bg-mnt-elevated',
danger: 'bg-[var(--mnt-status-down)] text-mnt-inverted hover:opacity-90',
ghost: 'text-mnt-muted hover:bg-mnt-elevated hover:text-mnt-primary',
+ 'danger-ghost': 'text-mnt-status-down hover:bg-[var(--mnt-status-down-bg)]',
}
@@ -48,10 +59,12 @@ const variantClasses: Record = {
diff --git a/frontend/src/components/ui/__tests__/ChipToggle.spec.ts b/frontend/src/components/ui/__tests__/ChipToggle.spec.ts
new file mode 100644
index 00000000..fcaca248
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/ChipToggle.spec.ts
@@ -0,0 +1,43 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ChipToggle from '@/components/ui/ChipToggle.vue'
+
+describe('ChipToggle', () => {
+ it('reflects pressed in aria-pressed', () => {
+ const wrapper = mount(ChipToggle, { props: { pressed: true }, slots: { default: 'critical' } })
+ expect(wrapper.find('button').attributes('aria-pressed')).toBe('true')
+ })
+
+ it('emits toggle on click', async () => {
+ const wrapper = mount(ChipToggle, { props: { pressed: false }, slots: { default: 'critical' } })
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.emitted('toggle')).toHaveLength(1)
+ })
+
+ it('does not emit toggle when disabled', async () => {
+ const wrapper = mount(ChipToggle, { props: { pressed: false, disabled: true }, slots: { default: 'critical' } })
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.emitted('toggle')).toBeUndefined()
+ })
+
+ it('renders a removable chip with an accessible remove button instead of a toggle', () => {
+ const wrapper = mount(ChipToggle, {
+ props: { removable: true, removeLabel: 'Remove container:42' },
+ slots: { default: 'container:42' },
+ })
+ expect(wrapper.find('button[aria-pressed]').exists()).toBe(false)
+ const removeButton = wrapper.find('button[aria-label="Remove container:42"]')
+ expect(removeButton.exists()).toBe(true)
+ })
+
+ it('emits remove when the remove button is clicked', async () => {
+ const wrapper = mount(ChipToggle, { props: { removable: true }, slots: { default: 'container:42' } })
+ await wrapper.find('button[aria-label="Remove"]').trigger('click')
+ expect(wrapper.emitted('remove')).toHaveLength(1)
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/ColorInput.spec.ts b/frontend/src/components/ui/__tests__/ColorInput.spec.ts
new file mode 100644
index 00000000..93fea07e
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/ColorInput.spec.ts
@@ -0,0 +1,31 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ColorInput from '@/components/ui/ColorInput.vue'
+
+describe('ColorInput', () => {
+ it('renders a color swatch and a text field with the same value', () => {
+ const wrapper = mount(ColorInput, { props: { modelValue: '#22C55E', label: 'Accent' } })
+ expect(wrapper.find('input[type="color"]').element).toHaveProperty('value', '#22c55e')
+ expect(wrapper.find('input[type="text"]').element).toHaveProperty('value', '#22C55E')
+ })
+
+ it('emits update:modelValue when the swatch changes', async () => {
+ const wrapper = mount(ColorInput, { props: { modelValue: '#000000' } })
+ const swatch = wrapper.find('input[type="color"]')
+ await swatch.setValue('#ffffff')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ffffff'])
+ })
+
+ it('emits update:modelValue when the hex text field changes', async () => {
+ const wrapper = mount(ColorInput, { props: { modelValue: '#000000' } })
+ const text = wrapper.find('input[type="text"]')
+ await text.setValue('#123ABC')
+ await text.trigger('change')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#123ABC'])
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/DisclosureButton.spec.ts b/frontend/src/components/ui/__tests__/DisclosureButton.spec.ts
new file mode 100644
index 00000000..4c29976c
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/DisclosureButton.spec.ts
@@ -0,0 +1,40 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import DisclosureButton from '@/components/ui/DisclosureButton.vue'
+
+describe('DisclosureButton', () => {
+ it('reflects expanded state in aria-expanded', () => {
+ const wrapper = mount(DisclosureButton, { props: { expanded: true }, slots: { default: 'Group' } })
+ expect(wrapper.find('button').attributes('aria-expanded')).toBe('true')
+ })
+
+ it('sets aria-controls from controlsId', () => {
+ const wrapper = mount(DisclosureButton, {
+ props: { expanded: false, controlsId: 'panel-1' },
+ slots: { default: 'Group' },
+ })
+ expect(wrapper.find('button').attributes('aria-controls')).toBe('panel-1')
+ })
+
+ it('emits toggle on click', async () => {
+ const wrapper = mount(DisclosureButton, { props: { expanded: false }, slots: { default: 'Group' } })
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.emitted('toggle')).toHaveLength(1)
+ })
+
+ it('places the chevron after the content when chevronPosition is end', () => {
+ const wrapper = mount(DisclosureButton, {
+ props: { expanded: false, chevronPosition: 'end' },
+ slots: { default: 'Component' },
+ })
+ const button = wrapper.find('button')
+ const label = button.find('.label').element
+ const chevron = button.find('svg').element
+ expect(label.compareDocumentPosition(chevron) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/FileInput.spec.ts b/frontend/src/components/ui/__tests__/FileInput.spec.ts
new file mode 100644
index 00000000..20f08f35
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/FileInput.spec.ts
@@ -0,0 +1,38 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import FileInput from '@/components/ui/FileInput.vue'
+
+function makeFile(name = 'logo.png') {
+ return new File(['x'], name, { type: 'image/png' })
+}
+
+describe('FileInput', () => {
+ it('shows no filename and no clear button until a file is picked', () => {
+ const wrapper = mount(FileInput, { props: { file: null } })
+ expect(wrapper.text()).not.toContain('.png')
+ expect(wrapper.findAll('button')).toHaveLength(1)
+ })
+
+ it('emits update:file with the picked file', async () => {
+ const wrapper = mount(FileInput, { props: { file: null } })
+ const file = makeFile()
+ const input = wrapper.find('input[type="file"]')
+ Object.defineProperty(input.element, 'files', { value: [file] })
+ await input.trigger('change')
+ expect(wrapper.emitted('update:file')?.[0]).toEqual([file])
+ })
+
+ it('shows the filename and a clear button once a file is set, and clears on click', async () => {
+ const wrapper = mount(FileInput, { props: { file: makeFile('hero.png') } })
+ expect(wrapper.text()).toContain('hero.png')
+ const buttons = wrapper.findAll('button')
+ expect(buttons).toHaveLength(2)
+ await buttons[1]!.trigger('click')
+ expect(wrapper.emitted('update:file')?.[0]).toEqual([null])
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/OptionCards.spec.ts b/frontend/src/components/ui/__tests__/OptionCards.spec.ts
new file mode 100644
index 00000000..08bed2d0
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/OptionCards.spec.ts
@@ -0,0 +1,47 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import OptionCards, { type OptionCardItem } from '@/components/ui/OptionCards.vue'
+
+const options: OptionCardItem[] = [
+ { value: 'discord', label: 'Discord', description: 'Webhook' },
+ { value: 'webhook', label: 'Webhook', description: 'HTTP POST' },
+ { value: 'email', label: 'Email', description: 'SMTP', disabled: true },
+]
+
+describe('OptionCards', () => {
+ it('marks the selected option with aria-checked', () => {
+ const wrapper = mount(OptionCards, { props: { options, ariaLabel: 'Channel type', modelValue: 'webhook' } })
+ const radios = wrapper.findAll('[role="radio"]')
+ expect(radios[0]?.attributes('aria-checked')).toBe('false')
+ expect(radios[1]?.attributes('aria-checked')).toBe('true')
+ })
+
+ it('updates the model on click', async () => {
+ const wrapper = mount(OptionCards, { props: { options, ariaLabel: 'Channel type', modelValue: null } })
+ await wrapper.findAll('[role="radio"]')[0]?.trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['discord'])
+ })
+
+ it('does not select a disabled option', async () => {
+ const wrapper = mount(OptionCards, { props: { options, ariaLabel: 'Channel type', modelValue: null } })
+ await wrapper.findAll('[role="radio"]')[2]?.trigger('click')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('moves focus with arrow keys, wrapping at the ends', async () => {
+ const wrapper = mount(OptionCards, {
+ props: { options, ariaLabel: 'Channel type', modelValue: null },
+ attachTo: document.body,
+ })
+ const radios = wrapper.findAll('[role="radio"]')
+ ;(radios[0]!.element as HTMLElement).focus()
+ await radios[0]!.trigger('keydown', { key: 'ArrowLeft' })
+ expect(document.activeElement).toBe(radios[2]!.element)
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/PopoverMenu.spec.ts b/frontend/src/components/ui/__tests__/PopoverMenu.spec.ts
new file mode 100644
index 00000000..94919c33
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/PopoverMenu.spec.ts
@@ -0,0 +1,61 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PopoverMenu from '@/components/ui/PopoverMenu.vue'
+
+function mountMenu(open = false) {
+ return mount(PopoverMenu, {
+ props: { ariaLabel: 'Demo menu', open },
+ slots: {
+ trigger: 'Open ',
+ default: `
+ A
+ B
+ `,
+ },
+ attachTo: document.body,
+ })
+}
+
+describe('PopoverMenu', () => {
+ it('renders the panel only when open', () => {
+ const closed = mountMenu(false)
+ expect(closed.find('[role="menu"]').exists()).toBe(false)
+
+ const open = mountMenu(true)
+ expect(open.find('[role="menu"]').exists()).toBe(true)
+ open.unmount()
+ closed.unmount()
+ })
+
+ it('closes on Escape', async () => {
+ const wrapper = mountMenu(true)
+ await wrapper.trigger('keydown', { key: 'Escape' })
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([false])
+ wrapper.unmount()
+ })
+
+ it('closes on an outside pointerdown', async () => {
+ const wrapper = mountMenu(true)
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([false])
+ wrapper.unmount()
+ })
+
+ it('moves focus between items with ArrowDown/ArrowUp', async () => {
+ const wrapper = mountMenu(true)
+ const items = wrapper.findAll('[role="menuitem"]')
+ ;(items[0]!.element as HTMLElement).focus()
+ await wrapper.trigger('keydown', { key: 'ArrowDown' })
+ expect(document.activeElement).toBe(items[1]!.element)
+
+ await wrapper.trigger('keydown', { key: 'ArrowUp' })
+ expect(document.activeElement).toBe(items[0]!.element)
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/RangeInput.spec.ts b/frontend/src/components/ui/__tests__/RangeInput.spec.ts
new file mode 100644
index 00000000..e041ee8a
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/RangeInput.spec.ts
@@ -0,0 +1,30 @@
+// Copyright 2026 Benjamin Touchard (Kolapsis)
+//
+// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
+// or a commercial license. See COMMERCIAL-LICENSE.md.
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import RangeInput from '@/components/ui/RangeInput.vue'
+
+describe('RangeInput', () => {
+ it('renders a native range input with the given bounds', () => {
+ const wrapper = mount(RangeInput, { props: { label: 'CPU threshold', min: 1, max: 100, modelValue: 90 } })
+ const input = wrapper.find('input[type="range"]')
+ expect(input.attributes('min')).toBe('1')
+ expect(input.attributes('max')).toBe('100')
+ expect(input.attributes('aria-label')).toBe('CPU threshold')
+ })
+
+ it('emits update:modelValue as a number on input', async () => {
+ const wrapper = mount(RangeInput, { props: { label: 'CPU threshold', min: 1, max: 100, modelValue: 50 } })
+ const input = wrapper.find('input')
+ await input.setValue('75')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([75])
+ })
+
+ it('derives the gradient fill from the current value', () => {
+ const wrapper = mount(RangeInput, { props: { label: 'CPU threshold', min: 0, max: 100, modelValue: 25 } })
+ expect((wrapper.find('input').element as HTMLElement).style.getPropertyValue('--fill')).toBe('25%')
+ })
+})
diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue
index 30dc0db6..0584db2b 100644
--- a/frontend/src/layouts/DefaultLayout.vue
+++ b/frontend/src/layouts/DefaultLayout.vue
@@ -274,14 +274,13 @@ watch(
+
+
+
+
+ Aa
+
@@ -543,6 +594,131 @@ const demoModalOpen = ref(false)
+
+
+
+
+
+
+
+ {{ demoPopoverNamespaces.length }} namespaces
+
+
+ n !== ns)
+ : [...demoPopoverNamespaces, ns]
+ "
+ >
+ {{ ns }}
+ ✓
+
+
+
+
+
+
+
+
+
+
+
+ production
+
+
+
+
+ 12 containers, all running.
+
+
+
+
+
+
+
+
+ s !== sev)
+ : [...demoChipSeverities, sev]
+ "
+ >
+ {{ sev }}
+
+ accent
+
+
+
+ {{ m.name }}
+
+ All removed
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tokens-only · dual-theme · keyboard-navigable · respects reduced-motion
diff --git a/frontend/src/pages/EndpointsPage.vue b/frontend/src/pages/EndpointsPage.vue
index dc0e431e..4ce64d8c 100644
--- a/frontend/src/pages/EndpointsPage.vue
+++ b/frontend/src/pages/EndpointsPage.vue
@@ -249,10 +249,9 @@ onUnmounted(() => {
(form.endpoint_type = v as 'http' | 'tcp')"
/>
diff --git a/frontend/src/pages/PublicStatusPage.vue b/frontend/src/pages/PublicStatusPage.vue
index f224d8cc..3ac8d9ae 100644
--- a/frontend/src/pages/PublicStatusPage.vue
+++ b/frontend/src/pages/PublicStatusPage.vue
@@ -17,6 +17,7 @@ import StatusComponentBreakdown from '@/components/StatusComponentBreakdown.vue'
import { useStatusPageI18n } from '@/composables/useStatusPageI18n'
import { guardedFetch } from '@/services/apiFetch'
import type { MonitorRef } from '@/services/statusApi'
+import DisclosureButton from '@/components/ui/DisclosureButton.vue'
// --- Personalization settings ---
interface PublicSettings {
@@ -309,27 +310,34 @@ function formatDate(iso: string) {
}"
>
-
+
+ {{ comp.name }}
+
+
+ {{ componentStatusStyle(comp.status).label }}
+
+
+
+
+
+
{{ comp.name }}
{{ componentStatusStyle(comp.status).label }}
- ⌄
-
+
diff --git a/frontend/src/pages/ServicesPage.vue b/frontend/src/pages/ServicesPage.vue
index 66e81419..c034b824 100644
--- a/frontend/src/pages/ServicesPage.vue
+++ b/frontend/src/pages/ServicesPage.vue
@@ -19,7 +19,8 @@ import { useTopologyRefetch } from '@/composables/useTopologyRefetch'
import { detailSlideOverKey } from '@/composables/useDetailSlideOver'
import HostBadge from '@/components/HostBadge.vue'
import { timeAgo } from '@/utils/time'
-import { Layers, ChevronDown, ChevronRight } from 'lucide-vue-next'
+import { Layers } from 'lucide-vue-next'
+import DisclosureButton from '@/components/ui/DisclosureButton.vue'
interface ServiceGroup {
stack: string
@@ -144,44 +145,44 @@ function handleSelect(svc: SwarmServiceResponse) {
class="bg-mnt-surface rounded-xl border border-mnt-default overflow-hidden"
>
-
-
-
- {{ group.stack }}
-
- {{ group.services.length }} service{{ group.services.length === 1 ? '' : 's' }}
-
-
-
-
- {{ group.services.reduce((sum, s) => sum + s.running_replicas, 0) }}/{{
- group.services.reduce((sum, s) => sum + s.desired_replicas, 0)
- }}
-
+
+
+ {{ group.stack }}
+
+ {{ group.services.length }} service{{ group.services.length === 1 ? '' : 's' }}
+
+
+
+
+ {{ group.services.reduce((sum, s) => sum + s.running_replicas, 0) }}/{{
+ group.services.reduce((sum, s) => sum + s.desired_replicas, 0)
+ }}
+
+
-
+
{
store.disconnectSSE()
})
-const tabItems = computed(() => [
+const tabItems = computed[]>(() => [
{ value: 'components', label: 'Components', count: store.components?.length || undefined },
{ value: 'incidents', label: 'Incidents', count: store.incidentsTotal || undefined },
{ value: 'maintenance', label: 'Maintenance', count: store.maintenance?.length || undefined },
- { value: 'subscribers', label: 'Subscribers', count: store.subscriberTotal || undefined },
+ {
+ value: 'subscribers',
+ label: 'Subscribers',
+ count: store.subscriberTotal ? `${store.subscriberConfirmed}/${store.subscriberTotal}` : undefined,
+ },
{ value: 'smtp', label: 'SMTP' },
{ value: 'personalization', label: 'Personalization' },
])
@@ -172,11 +176,10 @@ const tabItems = computed(() => [
(activeTab = v as Tab)"
/>
diff --git a/frontend/src/pages/WebhooksPage.vue b/frontend/src/pages/WebhooksPage.vue
index 1c8f81b0..f9173e1e 100644
--- a/frontend/src/pages/WebhooksPage.vue
+++ b/frontend/src/pages/WebhooksPage.vue
@@ -101,7 +101,7 @@
>
{{ testing === wh.id ? 'Testing...' : 'Test' }}
-
+
Delete
From 10d6877444336650d2a77ee35ddd0e2eabf2470f Mon Sep 17 00:00:00 2001
From: Benjamin
Date: Mon, 28 Sep 2026 21:22:29 +0200
Subject: [PATCH 5/5] chore(ui): remove the dev-only design-system page
The /_ds gallery only existed in development builds and no one runs one;
each shared component keeps its own unit tests.
---
frontend/src/pages/DesignSystemPage.vue | 727 ------------------------
frontend/src/router/index.ts | 10 -
2 files changed, 737 deletions(-)
delete mode 100644 frontend/src/pages/DesignSystemPage.vue
diff --git a/frontend/src/pages/DesignSystemPage.vue b/frontend/src/pages/DesignSystemPage.vue
deleted file mode 100644
index 9e6054c6..00000000
--- a/frontend/src/pages/DesignSystemPage.vue
+++ /dev/null
@@ -1,727 +0,0 @@
-
-
-
-
-
-
- Design system
- /_ds
-
-
-
- {{ resolvedTheme === 'dark' ? 'Light' : 'Dark' }}
-
-
-
-
-
-
-
-
-
-
-
- {{ s }}
-
-
- muted wash + AA text
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- selected: {{ lastSelected }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Hover me
-
-
-
-
-
-
-
-
-
-
-
- Secondary filters live here, one per page.
-
-
-
-
-
- {{ row.name }}
- {{ row.target }}
-
-
- {{ row.responseMs ? `${row.responseMs}ms` : '-' }}
-
-
-
-
-
-
- {{ row.name }}
-
-
- {{ row.target }}
-
-
-
-
-
- {{ row.responseMs ? `${row.responseMs}ms` : '-' }}
-
-
-
-
-
-
- {{ row.name }}
-
-
- {{ row.target }}
-
-
-
-
-
-
-
-
-
- 4.2 GB / 16 GB RAM · 24 containers · matrix-coturn 38%
-
- Panel contents. Collapsed, the summary above stands in for it.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Active tab: {{ demoTab }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Primary
- Secondary
- Danger
-
- Danger ghost
-
- Ghost
- Small
- Disabled
-
- {{ demoButtonLoading ? 'Saving…' : 'Click to load' }}
-
-
-
- Row action (block + align="between")
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- md ·
-
- sm ·
-
-
-
-
-
-
- Aa
-
-
-
-
-
-
-
-
-
- (demoTags = v as string[])" />
- (demoTags = v as string[])" />
- Selected: {{ demoTags.join(', ') || 'none' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Open modal
-
- This removes the monitor and its history. This cannot be undone.
-
- Cancel
- Delete
-
-
-
-
-
-
-
-
-
-
-
-
- {{ demoPopoverNamespaces.length }} namespaces
-
-
- n !== ns)
- : [...demoPopoverNamespaces, ns]
- "
- >
- {{ ns }}
- ✓
-
-
-
-
-
-
-
-
-
-
-
- production
-
-
-
-
- 12 containers, all running.
-
-
-
-
-
-
-
-
- s !== sev)
- : [...demoChipSeverities, sev]
- "
- >
- {{ sev }}
-
- accent
-
-
-
- {{ m.name }}
-
- All removed
-
-
-
-
-
-
-
-
-
-
-
- {{ option.label }}
- {{ option.description }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tokens-only · dual-theme · keyboard-navigable · respects reduced-motion
-
-
-
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 85974dcf..dadd6cfc 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -82,16 +82,6 @@ const router = createRouter({
{ path: 'editions', name: 'editions', component: EditionsPage },
// The old offer page: keep the address working, send it to the comparison.
{ path: 'pro-edition', redirect: { name: 'editions' } },
- // Dev-only design system gallery (not linked in nav, excluded from prod build)
- ...(import.meta.env.DEV
- ? [
- {
- path: '_ds',
- name: 'design-system',
- component: () => import('../pages/DesignSystemPage.vue'),
- },
- ]
- : []),
],
},
{