Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/heartbeats.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ A monitoring tool cannot report its own outage. Outbound heartbeats solve this b
### Setting It Up

1. On the **other** instance, create a heartbeat monitor with an interval that matches the one you will use below, plus some grace time. Copy its ping URL.
2. On **this** instance, open **Heartbeats → Outgoing** and click **New target**.
2. On **this** instance, open **Outbound heartbeats** in the Administration section of the menu and click **New target**.
3. Paste the ping URL, give the target a name and choose an interval.
4. Click **Send now** to check the target right away. The row shows the HTTP status it received, or the error.

Expand Down
15 changes: 15 additions & 0 deletions frontend/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export default defineConfigWithVueTs(
files: ['src/**/__tests__/*'],
},

{
name: 'app/shared-ui-components',
files: ['src/**/*.vue'],
ignores: ['src/components/ui/**', 'src/**/__tests__/**'],
rules: {
'vue/no-restricted-html-elements': [
'error',
{ element: 'input', message: 'Use the shared TextInput, CheckboxInput, or RadioGroup component instead of a raw <input>.' },
{ element: 'select', message: 'Use the shared SelectInput component instead of a raw <select>.' },
{ element: 'textarea', message: 'Use the shared TextareaInput component instead of a raw <textarea>.' },
{ element: 'button', message: 'Use the shared UiButton, ToggleSwitch, or TabNav component instead of a raw <button>.' },
],
},
},

...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),

skipFormatting,
Expand Down
88 changes: 25 additions & 63 deletions frontend/src/components/AgentDetailPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
import { ref, watch, computed } from 'vue'
import SlideOverPanel from '@/components/ui/SlideOverPanel.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import UiButton from '@/components/ui/UiButton.vue'
import TextInput from '@/components/ui/TextInput.vue'
import { useAgentsStore } from '@/stores/agents'
import { useConfirm } from '@/composables/useConfirm'
import { osDaysText, osSupportHint, osSupportLabel, osSupportSeverity } from '@/utils/osSupport'
import type { Agent } from '@/services/agentApi'

Expand All @@ -31,6 +34,7 @@ const emit = defineEmits<{
}>()

const store = useAgentsStore()
const confirm = useConfirm()

const labelDraft = ref('')
const savingLabel = ref(false)
Expand All @@ -39,7 +43,6 @@ const labelSuccess = ref(false)

const revoking = ref(false)
const deleting = ref(false)
const showDeleteConfirm = ref(false)
const actionError = ref<string | null>(null)

watch(
Expand All @@ -49,7 +52,6 @@ watch(
labelError.value = null
labelSuccess.value = false
actionError.value = null
showDeleteConfirm.value = false
},
)

Expand Down Expand Up @@ -98,6 +100,14 @@ async function handleRevoke() {

async function handleDelete() {
if (!props.agent) return
const ok = await confirm({
title: 'Delete agent?',
message:
'This will permanently purge all historical events for this agent (containers, endpoints, heartbeats, resources, certificates) in a single transaction. This action is irreversible.',
confirmLabel: 'Delete permanently',
destructive: true,
})
if (!ok) return
deleting.value = true
actionError.value = null
try {
Expand All @@ -106,6 +116,7 @@ async function handleDelete() {
emit('update:open', false)
} catch (e) {
actionError.value = e instanceof Error ? e.message : 'Failed to delete agent'
} finally {
deleting.value = false
}
}
Expand Down Expand Up @@ -196,21 +207,16 @@ function runtimeLabel(rt: string): string {
<div>
<p class="text-[10px] text-mnt-muted font-bold uppercase tracking-widest mb-2">Label</p>
<div class="flex gap-2">
<input
<TextInput
v-model="labelDraft"
maxlength="64"
placeholder="Display label…"
class="flex-1 rounded-lg border border-mnt-default bg-mnt-primary px-3 py-2 text-sm text-mnt-primary placeholder:text-mnt-muted focus:outline-none focus:border-mnt-green-500"
class="flex-1"
@keydown.enter="saveLabel"
/>
<button
:disabled="savingLabel || labelDraft === agent.label"
class="rounded-lg px-3 py-2 text-sm font-medium transition-opacity disabled:opacity-40"
:style="{ backgroundColor: 'var(--mnt-accent)', color: 'var(--mnt-text-inverted)' }"
@click="saveLabel"
>
<UiButton variant="primary" :disabled="savingLabel || labelDraft === agent.label" @click="saveLabel">
{{ savingLabel ? '…' : 'Save' }}
</button>
</UiButton>
</div>
<p v-if="labelConflict" class="mt-1 text-xs text-yellow-500">
Another agent already uses this label.
Expand Down Expand Up @@ -268,65 +274,21 @@ function runtimeLabel(rt: string): string {
<div v-if="agent.status === 'active'" class="space-y-2">
<p class="text-[10px] text-mnt-muted font-bold uppercase tracking-widest mb-2">Actions</p>
<p v-if="actionError" class="text-xs text-mnt-status-down">{{ actionError }}</p>
<button
:disabled="revoking"
class="w-full rounded-lg border border-yellow-600/40 px-4 py-2 text-sm font-medium text-yellow-500 hover:bg-yellow-600/10 transition-colors disabled:opacity-40"
@click="handleRevoke"
>
<UiButton variant="secondary" class="w-full" :loading="revoking" :disabled="revoking" @click="handleRevoke">
{{ revoking ? 'Revoking…' : 'Revoke agent' }}
</button>
<button
class="w-full rounded-lg border border-red-600/40 px-4 py-2 text-sm font-medium text-mnt-status-down hover:bg-mnt-sev-incident-solid/10 transition-colors"
@click="showDeleteConfirm = true"
>
Delete agent
</button>
</UiButton>
<UiButton variant="danger-ghost" class="w-full" :loading="deleting" :disabled="deleting" @click="handleDelete">
{{ deleting ? 'Deleting…' : 'Delete agent' }}
</UiButton>
</div>

<div v-if="agent.status === 'revoked'" class="space-y-2">
<p class="text-[10px] text-mnt-muted font-bold uppercase tracking-widest mb-2">Actions</p>
<p v-if="actionError" class="text-xs text-mnt-status-down">{{ actionError }}</p>
<button
class="w-full rounded-lg border border-red-600/40 px-4 py-2 text-sm font-medium text-mnt-status-down hover:bg-mnt-sev-incident-solid/10 transition-colors"
@click="showDeleteConfirm = true"
>
Delete agent
</button>
<UiButton variant="danger-ghost" class="w-full" :loading="deleting" :disabled="deleting" @click="handleDelete">
{{ deleting ? 'Deleting…' : 'Delete agent' }}
</UiButton>
</div>
</div>

<!-- Delete confirmation modal -->
<Teleport to="body">
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-[10000] flex items-center justify-center"
@click.self="showDeleteConfirm = false"
>
<div class="absolute inset-0 bg-black/70" @click="showDeleteConfirm = false" />
<div class="relative z-10 w-full max-w-md mx-4 rounded-2xl border border-mnt-default bg-mnt-surface p-6 shadow-2xl">
<h3 class="text-base font-bold text-mnt-primary mb-2">Delete agent?</h3>
<p class="text-sm text-mnt-muted mb-4">
This will permanently purge all historical events for this agent
(containers, endpoints, heartbeats, resources, certificates) in a single transaction.
<strong class="text-mnt-status-down">This action is irreversible.</strong>
</p>
<div class="flex gap-3 justify-end">
<button
class="rounded-lg px-4 py-2 text-sm text-mnt-muted hover:text-mnt-primary transition-colors"
@click="showDeleteConfirm = false"
>
Cancel
</button>
<button
:disabled="deleting"
class="rounded-lg px-4 py-2 text-sm font-medium text-mnt-primary bg-mnt-sev-incident-solid hover:opacity-90 disabled:opacity-40 transition-colors"
@click="handleDelete"
>
{{ deleting ? 'Deleting…' : 'Delete permanently' }}
</button>
</div>
</div>
</div>
</Teleport>
</SlideOverPanel>
</template>
73 changes: 30 additions & 43 deletions frontend/src/components/AlertList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { detailSlideOverKey, type EntityType } from '@/composables/useDetailSlid
import type { Alert, ListAlertsParams } from '@/services/alertApi'
import { humanizeAlertType } from '@/utils/alertLabels'
import AcknowledgeButton from '@/components/ui/AcknowledgeButton.vue'
import SelectInput from '@/components/ui/SelectInput.vue'
import UiButton from '@/components/ui/UiButton.vue'

const detailSlideOver = inject(detailSlideOverKey)!
const store = useAlertsStore()
Expand Down Expand Up @@ -83,48 +85,38 @@ function openEntityDetail(alert: Alert) {
detailSlideOver.openDetail(alert.entity_type as EntityType, alert.entity_id)
}

const selectStyle = 'background: var(--mnt-bg-elevated); border-color: var(--mnt-border-default); color: var(--mnt-text-secondary)'
const sourceOptions = [
{ value: '', label: 'All sources' },
{ value: 'container', label: 'Container' },
{ value: 'endpoint', label: 'Endpoint' },
{ value: 'heartbeat', label: 'Heartbeat' },
{ value: 'certificate', label: 'Certificate' },
{ value: 'resource', label: 'Resource' },
{ value: 'agent', label: 'Agent' },
]

const severityOptions = [
{ value: '', label: 'All severities' },
{ value: 'critical', label: 'Critical' },
{ value: 'warning', label: 'Warning' },
{ value: 'info', label: 'Info' },
]

const statusOptions = [
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'silenced', label: 'Silenced' },
]
</script>

<template>
<div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-3">
<select
v-model="sourceFilter"
class="rounded-md border px-3 py-1.5 text-sm outline-none"
:style="selectStyle"
>
<option value="">All sources</option>
<option value="container">Container</option>
<option value="endpoint">Endpoint</option>
<option value="heartbeat">Heartbeat</option>
<option value="certificate">Certificate</option>
<option value="resource">Resource</option>
<option value="agent">Agent</option>
</select>

<select
v-model="severityFilter"
class="rounded-md border px-3 py-1.5 text-sm outline-none"
:style="selectStyle"
>
<option value="">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>

<select
v-model="statusFilter"
class="rounded-md border px-3 py-1.5 text-sm outline-none"
:style="selectStyle"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
<option value="silenced">Silenced</option>
</select>
<SelectInput v-model="sourceFilter" size="sm" class="w-auto" :options="sourceOptions" />
<SelectInput v-model="severityFilter" size="sm" class="w-auto" :options="severityOptions" />
<SelectInput v-model="statusFilter" size="sm" class="w-auto" :options="statusOptions" />
</div>

<!-- Mobile card list -->
Expand Down Expand Up @@ -239,14 +231,9 @@ const selectStyle = 'background: var(--mnt-bg-elevated); border-color: var(--mnt

<!-- Load more -->
<div v-if="store.hasMore" class="mt-4 text-center">
<button
@click="loadMore"
:disabled="store.loading"
class="rounded-md border px-4 py-2 text-sm disabled:opacity-50 transition-colors"
style="border-color: var(--mnt-border-default); color: var(--mnt-text-secondary); background: var(--mnt-bg-surface)"
>
<UiButton variant="secondary" size="sm" :loading="store.loading" @click="loadMore">
{{ store.loading ? 'Loading...' : 'Load more' }}
</button>
</UiButton>
</div>
</div>
</template>
Loading