{% extends "base.html" %} {% block title %}{{ m.name }}{% endblock %} {% block content %}
Monitors / {{ mode_label }}

{% if not m.active %}{% elif m.is_up %}{% elif m.is_up is false %}{% else %}{% endif %}{{ m.name }} {% if not m.active %}paused{% elif m.is_up is false %}down{% elif m.is_up %}up{% else %}pending{% endif %} {% if muted %}muted{% endif %}{% if maint %}maintenance{% endif %}{% if m.slow_count >= 3 %}slow{% endif %} {% for t in m.tags or [] %}{{ t }}{% endfor %}{% if m.agent_id %}private location{% endif %}

{% if m.mode == 'heartbeat' %}
{% if m.schedule_cron %}Schedule {{ m.schedule_cron }} {{ m.schedule_tz }}{% else %}Expected every {{ m.period_minutes }} min{% endif %} · grace {{ m.grace_minutes }} min{% if m.max_duration_minutes %} · max run {{ m.max_duration_minutes }} min{% endif %} · {{ m.ping_count }} pings · last ping {{ m.last_ping_at|timeago }}{% if m.last_duration_ms %} · last run {{ fmt_duration(m.last_duration_ms // 1000) }}{% endif %}
{% elif m.mode == 'dependency' %}
{{ provider[0] if provider else m.url }} · status page ↗ · currently {{ m.last_indicator or 'unknown' }}
{% else %}
{% if m.mode in ('tcp','domain') %}{{ m.url }}{% else %}{{ m.url }}{% endif %}
{{ m.interval_minutes|interval }}{% if m.mode == 'api' %} · {{ m.method }} expects {{ m.expect_status }}{% endif %}{% if m.mode == 'flow' %} · {{ (m.steps or [])|length }} steps{% endif %}{% if m.max_latency_ms %} · SLA {{ m.max_latency_ms }} ms{% endif %}{% if m.selector %} · selector {{ m.selector }}{% endif %}{% if m.keyword %} · keyword {{ m.keyword }} {{ m.keyword_rule }}{% endif %}{% if m.mode == 'price' and m.price_rule != 'any' %} · alert when {{ m.price_rule }} {{ m.price_threshold }}{% endif %}{% if m.escalation_minutes %} · escalates after {{ m.escalation_minutes }} min{% endif %}
{% endif %}
{% if user.can_edit %}
{% if m.mode != 'heartbeat' %}
{% endif %} Edit
{% if muted %}{% else %} {% endif %}
{% endif %}
{% if open_inc %}
{{ open_inc.title }} · open for {{ fmt_duration(open_inc.duration_seconds) }}{% if open_inc.acknowledged_at %} · acknowledged by {{ open_inc.acknowledged_by }}{% elif open_inc.escalated_at %} · escalated{% endif %}{% if m.last_error %}
{{ m.last_error }}
{% endif %}
{% if not open_inc.acknowledged_at and user.can_edit %}
{% endif %}Incident details
{% elif m.last_error %}
{{ m.last_error }}
{% endif %} {% if not chans and not smtp_enabled and user.can_edit %}
This monitor has no alert channel, so alerts only show here. Add a channel, then select it.
{% endif %}
Uptime 24h · 7d · 30d
{{ a['24h'] if a['24h'] is not none else '—' }}{% if a['24h'] is not none %}%{% endif %} · {{ a['7d'] if a['7d'] is not none else '—' }}{% if a['7d'] is not none %}%{% endif %} · {{ a['30d'] if a['30d'] is not none else '—' }}{% if a['30d'] is not none %}%{% endif %}
downtime 30d: {{ a.downtime_30d }}
{% if m.mode == 'heartbeat' %}Run time{% else %}Latency p50 · p95 · p99{% endif %} (24h)
{% if a.p50 is not none %}{{ a.p50 }} · {{ a.p95 }} · {{ a.p99 }} ms{% else %}—{% endif %}
max {{ a.max if a.max is not none else '—' }} ms
Incidents 30d · MTTR
{{ a.incident_count }} · {{ a.mttr or '—' }}
{% if a.error_kinds %}{% for k, c in a.error_kinds[:3] %}{{ k }} ×{{ c }}{% if not loop.last %}, {% endif %}{% endfor %}{% else %}no failures in 7d{% endif %}
{% if m.mode == 'price' %}Current value{% elif m.mode in ('text','keyword') %}Changes detected{% elif m.mode == 'domain' %}Domain expires{% elif m.mode == 'dependency' %}Provider status{% elif m.security_scan %}Security grade{% else %}SSL certificate{% endif %}
{% if m.mode == 'price' %}{{ m.last_value if m.last_value is not none else '—' }}{% elif m.mode in ('text','keyword') %}{{ m.change_count }}{% elif m.mode == 'domain' %}{% if domain_days is not none %}{{ domain_days }} days{% else %}—{% endif %}{% elif m.mode == 'dependency' %}{{ m.last_indicator or '—' }}{% elif m.security_scan %}{{ m.security_scan.grade }}{% if ssl_days is not none %} SSL {{ ssl_days }}d{% endif %}{% elif ssl_days is not none %}{{ ssl_days }} days{% else %}—{% endif %}
last check {{ m.last_checked_at|timeago }} · next {{ next_in }}
{% if a.slo %}
SLO target · 30 days
{{ a.slo.target }}% {{ 'met' if a.slo.met else 'breached' }}
Achieved
{{ a.slo.achieved if a.slo.achieved is not none else '—' }}%
Error budget: {{ a.slo.used }} used of {{ a.slo.budget }} · {{ a.slo.remaining }} left
SLA report
{% endif %}
Overview Checks{% if m.mode == 'heartbeat' %} & logs{% endif %} Incidents & alerts {% if m.mode == 'flow' %}Steps{% endif %} {% if m.mode == 'health' %}Health checks{% endif %} {% if m.mode == 'domain' %}DNS & registry{% endif %} {% if m.url and m.url.startswith('https://') and m.mode not in ('flow','dependency') %}Security{% if m.security_scan %} {{ m.security_scan.grade }}{% endif %}{% endif %}
{# ================= OVERVIEW ================= #} {% if tab == 'overview' %} {% if m.mode == 'heartbeat' %}

Ping URLs

Success (call when the job finishes)
{{ base_url }}/ping/{{ m.ping_token }}
Start (optional, enables run-time tracking)
{{ base_url }}/ping/{{ m.ping_token }}/start
Failure, or exit code (0 = ok)
{{ base_url }}/ping/{{ m.ping_token }}/fail
{{ base_url }}/ping/{{ m.ping_token }}/$?
Keep the job's output as a log (POST body, up to 100 KB)
./job.sh 2>&1 | curl -fsS --data-binary @- {{ base_url }}/ping/{{ m.ping_token }}
Copy-paste examples
# crontab: start, run, report exit code with output
0 2 * * * curl -fsS -m 10 {{ base_url }}/ping/{{ m.ping_token }}/start >/dev/null; /usr/local/bin/backup.sh > /tmp/backup.log 2>&1; curl -fsS -m 10 --data-binary @/tmp/backup.log {{ base_url }}/ping/{{ m.ping_token }}/$? >/dev/null

# GitHub Actions / any CI
- run: curl -fsS {{ base_url }}/ping/{{ m.ping_token }}/start
- run: make deploy
- run: curl -fsS {{ base_url }}/ping/{{ m.ping_token }}
  if: success()
- run: curl -fsS {{ base_url }}/ping/{{ m.ping_token }}/fail
  if: failure()

# Python
import httpx; httpx.get("{{ base_url }}/ping/{{ m.ping_token }}/start")
...
httpx.post("{{ base_url }}/ping/{{ m.ping_token }}", content=log_text)
{% endif %} {% if latencies|length > 1 %}
{% if m.mode == 'heartbeat' %}Run time{% else %}Response time{% endif %}, last {{ latencies|length }} successful checks{% if m.max_latency_ms %} · dashed line = {{ m.max_latency_ms }} ms SLA{% endif %}{% if annotations %} · ▲ markers = deploys{% endif %} {% if user.can_edit %}
{% endif %}
{% set lmax = [latencies|map(attribute='latency_ms')|max, m.max_latency_ms or 0]|max %} {% set t0 = latencies[0].created_at %}{% set t1 = latencies[-1].created_at %}{% set span = (t1 - t0).total_seconds() or 1 %} {% if m.max_latency_ms %}{% endif %} {% for an in annotations %}{% set x = ((an.at - t0).total_seconds() / span * 600) %}{% if x >= 0 and x <= 600 %}{{ an.text }} · {{ an.at|dt }}{{ an.text }}{% endif %}{% endfor %} {% if annotations %}
Markers: {% for an in annotations[:5] %}{{ an.text }} · {{ an.at|timeago }}{% endfor %}
{% endif %}
{% endif %} {% if annotations and latencies|length <= 1 %}
Markers: {% for an in annotations[:5] %}{{ an.text }} · {{ an.at|timeago }}{% endfor %}
{% endif %} {% if checks %}
Last {{ checks|length }} checks (oldest → newest). Green = ok, blue = changed, red = failed, amber = retried.
{% for c in checks|reverse %}{% endfor %}
{% endif %} {% if m.mode == 'price' and values|length > 1 %}

Value history

{% set vmax = values|map(attribute='value')|max %}{% set vmin = values|map(attribute='value')|min %}
min {{ vmin }} · max {{ vmax }} · latest {{ values[-1].value }}
{% endif %}
{% if m.mode == 'api' %}

Assertions

{% if latest_assert %}
Evaluated {{ latest_assert.created_at|timeago }}
{% for r in latest_assert.assertion_results %}{% endfor %}
{% if r.ok %}pass{% else %}fail{% endif %}{{ r.rule }}{{ r.detail }}
{% elif m.assertions %}

No evaluation recorded yet.

{% else %}

No assertions configured. Add JSON, text or header assertions so a "200 OK" with a broken body still fails.

{% endif %}
{% endif %} {% if m.mode == 'dependency' and checks and checks[0].detail %}

Provider report

{{ checks[0].detail.description }}

{% for t in checks[0].detail.incidents or [] %}
• {{ t }}
{% endfor %}
{% endif %} {% if latest_evidence and m.mode != 'heartbeat' %}

Last failure evidence

{{ latest_evidence.created_at|dt }} · {{ latest_evidence.error }}
Status
HTTP {{ latest_evidence.evidence.status }}
Timing
DNS {{ latest_evidence.evidence.timing.dns_ms }} ms · first byte {{ latest_evidence.evidence.timing.ttfb_ms }} ms · total {{ latest_evidence.evidence.timing.total_ms }} ms
{% for k, v in latest_evidence.evidence.headers.items() %}
{{ k }}
{{ v }}
{% endfor %}
Response body ({{ latest_evidence.evidence.body|length }} chars{% if latest_evidence.evidence.body_truncated %}, truncated{% endif %})
{{ latest_evidence.evidence.body }}
Full check record →
{% endif %}

{% if m.mode == 'text' %}Latest change{% elif m.mode == 'domain' %}DNS records{% else %}Content{% endif %}

{% if m.mode == 'domain' %} {% if m.dns_records %}{% for t, vals in m.dns_records.items() %}{% endfor %}
{{ t }}{{ vals|join('
')|safe }}
{% else %}

No snapshot yet.

{% endif %}
Registrar: {{ m.domain_registrar or '—' }} · expires {{ m.domain_expires_at|dt }}
{% elif latest_diff %}
{{ latest_diff.created_at|dt }} · full snapshot · RSS of changes
{% for line in latest_diff.diff.splitlines() %}{% if line.startswith('+') %}{{ line }}{% elif line.startswith('-') %}{{ line }}{% else %}{{ line }}{% endif %}{% endfor %}
{% elif m.mode == 'text' %}

No changes detected yet. When the page changes you'll see exactly which lines were added and removed here.

{% elif m.mode == 'heartbeat' %}

Job output posted with pings appears under the Checks & logs tab.

{% elif m.mode in ('uptime','api','tcp','flow','dependency','health') %}

This monitor type doesn't diff content. Failure evidence (headers, body, timing) is captured on every failed check.

{% endif %} {% if m.last_text %}
Current snapshot ({{ m.last_text|length }} chars)
{{ m.last_text[:8000] }}{% if m.last_text|length > 8000 %} …{% endif %}
{% endif %}

Alerting

Channels
{% if chans %}{% for c in chans %}{{ c.name }} {% endfor %}{% elif smtp_enabled %}{{ ws.email }}{% else %}dashboard only{% endif %}
Escalation
{% if m.escalation_minutes %}after {{ m.escalation_minutes }} min → {% for c in esc_chans %}{{ c.name }} {% endfor %}{% else %}off{% endif %}
Repeat
{% if m.repeat_alert_minutes %}every {{ m.repeat_alert_minutes }} min while down{% else %}once{% endif %}
Confirmation
{% if m.confirm_retry %}re-check after 12 s, down after 2 fails{% else %}down after 2 consecutive fails{% endif %}

Incidents (30 days)

{% if a.incidents %}
{% for i in a.incidents %}{% endfor %}
{{ i.started_at|timeago }}{{ i.kind }}{% if not i.resolved_at %} · open{% endif %}{{ fmt_duration(i.duration_seconds) }}
{{ (i.cause or '')[:100] }}
{% else %}

No incidents. 🎉

{% endif %}

Recent alerts

{% if alerts %}
{% for al in alerts[:10] %}{% endfor %}
{{ al.created_at|timeago }}{{ al.kind }}{{ al.title }}
{% for k, v in al.delivery.items() %}{{ k.split(':')[0] }}: {{ 'sent' if v == 'sent' else ('skipped' if k in ('muted','maintenance') else 'failed') }}{% if not loop.last %} · {% endif %}{% endfor %}
{% else %}

No alerts yet.

{% endif %}
{# ================= CHECKS ================= #} {% elif tab == 'checks' %} {% if m.mode in ('uptime','api','text','price','keyword','health') and user.can_edit %}

Burst test

Fires 20 requests, 5 at a time, and reports latency percentiles, errors and rate-limit (429) responses. Good for spotting rate limits, cold starts and flaky endpoints.

{% if m.last_burst %}{% set b = m.last_burst %}
OK / total
{{ b.ok }}/{{ b.requests }}
p50 · p95 · max
{{ b.p50 }} · {{ b.p95 }} · {{ b.max }} ms
Throughput
{{ b.rps }} req/s
Rate limited
{{ b.rate_limited }}
{{ b.at }} · {% for k, v in b.statuses.items() %}{{ k }} × {{ v }} {% endfor %}{% if b.sample_errors %}
{{ b.sample_errors|join(' · ') }}
{% endif %}
{% endif %}
{% endif %} {% if m.mode == 'heartbeat' and runs %}

Run durations (last {{ runs|length }})

{% set rmax = runs|map(attribute='duration_ms')|max %}
{% for c in runs|reverse %}{% endfor %}
longest {{ fmt_duration(rmax // 1000) }}{% if m.max_duration_minutes %} · limit {{ m.max_duration_minutes }} min{% endif %}
{% endif %}
{% for c in checks[:80] %} {% endfor %} {% if not checks %}{% endif %}
WhenResultDetail
{{ c.created_at|dt }} {% if c.detail and c.detail.get('event') == 'start' %}start{% elif c.ok %}{{ c.status_code or 'ok' }}{% else %}{{ c.error_kind or 'fail' }}{% endif %}{% if c.changed %} changed{% endif %}{% if c.detail and c.detail.get('recovered_on_retry') %} retried{% endif %} {% if c.location %}{{ c.location }} {% endif %}{% if c.ok %}{% if c.duration_ms is not none %}ran {{ fmt_duration(c.duration_ms // 1000) }}{% elif c.latency_ms is not none %}{{ c.latency_ms }} ms{% else %}ping{% endif %}{% if c.value is not none %} · value {{ c.value }}{% endif %}{% if c.detail and c.detail.get('exit_code') is not none %} · exit {{ c.detail.exit_code }}{% endif %}{% else %}{{ (c.error or '')[:110] }}{% endif %}{% if c.output %}
log ({{ c.output|length }} chars)
{{ c.output[:3000] }}
{% endif %}
{% if c.diff or c.evidence or c.assertion_results or (c.detail and c.detail.get('steps')) or c.snapshot %}details{% endif %}
No checks yet.
{# ================= INCIDENTS ================= #} {% elif tab == 'incidents' %}

Incidents (30 days)

{% if a.incidents %} {% else %}

No incidents in the last 30 days.

{% endif %} {% if a.error_kinds %}
Failure causes (7d): {% for k, c in a.error_kinds %}{{ k }} × {{ c }} {% endfor %}
{% endif %}

Alerts

{% if alerts %}
{% for al in alerts %}{% endfor %}
{{ al.created_at|dt }}{{ al.kind }}{{ al.title }}
{{ al.body[:300] }}
{% for k, v in al.delivery.items() %}{{ k.split(':')[0] }}: {{ v }}{% if not loop.last %} · {% endif %}{% endfor %}
{% else %}

No alerts yet.

{% endif %}
{# ================= FLOW ================= #} {% elif tab == 'flow' %}

Last run

{% if latest_flow %}{% set r = latest_flow.detail %}
{{ latest_flow.created_at|dt }} · {{ 'passed' if r.ok else 'failed at step ' ~ r.failed_step }} · {{ r.total_ms }} ms total{% if r.recovered_on_retry %} · recovered on retry{% endif %}
{% else %}

No run recorded yet.

{% endif %}

Steps

{% for s in m.steps or [] %}
{{ loop.index }}. {{ s.name }} {{ s.method }} expects {{ s.expect_status }}
{{ s.url }}
{% if s.assertions %}
assert: {{ s.assertions|replace('\n', ' · ') }}
{% endif %}{% if s.extract %}
extract: {% for k, v in s.extract.items() %}{{ k }} ← {{ v }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
{% endfor %} Edit steps
{# ================= HEALTH ================= #} {% elif tab == 'health' %}

Reported checks

{% if m.health_results %}{% for c in m.health_results %}{% endfor %}
{{ c.status }}{{ c.name }}
{{ c.message }}
{% else %}

Nothing received yet. Make sure the endpoint returns JSON and, if you set a secret, checks the vigil-health-secret header.

{% endif %}

Set up your endpoint

Secret header sent with every request:

vigil-health-secret: {{ m.health_secret or '(none)' }}
{% if user.can_edit %}
{% endif %}

Return any of these shapes:

{"checkResults": [
  {"name": "Database", "status": "ok"},
  {"name": "Queue", "status": "failed", "notificationMessage": "1,204 jobs waiting"}
]}
// or Laravel Health: {"status":"ok","checks":{"db":{"status":"ok"}}}
// or Spring Actuator: {"status":"UP","components":{...}}
// or simply: {"status": "ok"}

Each check gets its own alert when it fails and when it recovers. Any failed check marks the monitor down; warning keeps it up but shows here.

{# ================= DNS ================= #} {% elif tab == 'dns' %}

Registry (RDAP)

Domain
{{ m.url }}
Expires
{{ m.domain_expires_at|dt }}{% if domain_days is not none %} {{ domain_days }} days{% endif %}
Registrar
{{ m.domain_registrar or '—' }}
Alert
{{ m.domain_alert_days }} days before expiry
{% if checks and checks[0].detail and checks[0].detail.get('domain_status') %}
Status codes
{% for s in checks[0].detail.domain_status %}{{ s }} {% endfor %}
{% endif %}

Registrars send renewal emails that get lost. "clientTransferProhibited" is good: it means the domain is locked against hijacking.

DNS snapshot

{% if m.dns_records %}{% for t, vals in m.dns_records.items() %}{% endfor %}
{{ t }}{{ vals|join('
')|safe }}
{% else %}

No snapshot yet.

{% endif %}
Watching: {% if m.dns_watch %}{{ m.dns_watch|join(', ') }}{% else %}all record types{% endif %} · resolved via 1.1.1.1 and 8.8.8.8
{% set dns_changes = checks|selectattr('detail')|selectattr('detail.dns_changes')|list %} {% if dns_changes %}

Change history

{% endif %}
{# ================= SECURITY ================= #} {% elif tab == 'security' %} {% set s = m.security_scan %}

Security posture

{% if user.can_edit %}
{% endif %}
{% if s %}
{{ s.grade }}
{{ s.score }}/100
scanned {{ m.security_scanned_at|timeago }} · rescans weekly{% if m.security_alerts %} · alerts on drop{% else %} · enable alerts on drop{% endif %}
{% if s.server %}
server: {{ s.server }}
{% endif %}
TLS: {% for k, v in s.tls.items() %}{{ k }} {{ 'on' if v else ('off' if v is false else '?') }} {% endfor %}
Headers present: {% for h in s.headers_present %}{{ h }} {% else %}none{% endfor %}
{% else %}

No scan yet. Scans check HSTS, CSP, X-Frame-Options, cookie flags, TLS 1.0–1.3 support and leaked server versions.

{% endif %}

Findings{% if s %} ({{ s.findings|length }}){% endif %}

{% if s and s.findings %}{% for f in s.findings %}
{{ f.title }} {{ f.severity }}
{{ f.advice }}
{% endfor %} {% elif s %}

Nothing to fix. 🎉

{% else %}

Run a scan to see findings and how to fix each one.

{% endif %}
{% endif %} {% endblock %}