Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
4250990
vendor pico css and alpine.js for frontend rebuild
kylerisse Feb 28, 2026
edb0f60
add unified app.css for frontend rebuild
kylerisse Feb 28, 2026
73cadd3
add unified app.js with alpine.js components
kylerisse Feb 28, 2026
cf27ed1
rewrite main dashboard with alpine.js and pico css
kylerisse Feb 28, 2026
dc8f9f8
update the grid view styling and layout
kylerisse Feb 28, 2026
8b9fb18
add omitted button to function as drop down
kylerisse Feb 28, 2026
00c8a8f
update host-detail page
kylerisse Feb 28, 2026
185fb30
cleanup unused files
kylerisse Feb 28, 2026
90b9a75
add tests for static frontend files
kylerisse Feb 28, 2026
2780f54
remove checks label and all button from host details page
kylerisse Feb 28, 2026
f64c44c
refresh host details page every minute
kylerisse Feb 28, 2026
1a01534
host status page now uses global omitted state
kylerisse Feb 28, 2026
e1e3513
reduce search box padding
kylerisse Feb 28, 2026
9bbbed0
create a larger sample-hosts file
kylerisse Feb 28, 2026
4327217
make grid-view fill screen
kylerisse Feb 28, 2026
4ce12f8
seperate api and graph polling intervals host-details page
kylerisse Feb 28, 2026
610f4ba
rate limit rrd graphing for longer intervals
kylerisse Feb 28, 2026
043fc5e
increase initial worker jitter from 1 to 2 minutes
kylerisse Feb 28, 2026
ea23c60
style the navigation elements
kylerisse Feb 28, 2026
e2679d8
reduce padding on host detail page
kylerisse Feb 28, 2026
e595457
reduce padding on main dashboard page
kylerisse Feb 28, 2026
6b92f5d
on host detail page, clicked graph fills page
kylerisse Feb 28, 2026
695e525
style main dashboard
kylerisse Feb 28, 2026
5810e37
add metrics to check ui elements
kylerisse Feb 28, 2026
2e6fe4d
add clearing x to search box
kylerisse Feb 28, 2026
8a97aa8
add clearing x for status filtering
kylerisse Feb 28, 2026
d936229
host details page add check stats cards
kylerisse Feb 28, 2026
a2baa68
host details page standardize check name displays
kylerisse Feb 28, 2026
a5941a0
rrd: still graph on partial check failures
kylerisse Feb 28, 2026
5363496
dashboard show failed checks with !
kylerisse Feb 28, 2026
9be5253
api: show failing checks as null
kylerisse Feb 28, 2026
6e9b8aa
set always shown statuses
kylerisse Feb 28, 2026
ef1a0d3
host-details center graphs
kylerisse Feb 28, 2026
d5bd00c
host-details check filter behaves like status filter
kylerisse Feb 28, 2026
839841b
sample-hosts: use example domains / ips
kylerisse Feb 28, 2026
a1eeb28
frontend: change Grid View to Grid on headings
kylerisse Feb 28, 2026
1851c60
frontend: remove unused statusLabel function
kylerisse Feb 28, 2026
882bb5c
frontend: extract shared mixin from Alpine.js components
kylerisse Feb 28, 2026
961cfb6
frontend: extract status colors into CSS custom properties
kylerisse Feb 28, 2026
356f8fb
frontend: consolidate full-width main layout into shared class
kylerisse Feb 28, 2026
8e0e18c
frontend: merge duplicate .grid-item CSS rule blocks
kylerisse Feb 28, 2026
21726dc
frontend: fix grid overflow
kylerisse Feb 28, 2026
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pkg/server/static/vendor/** linguist-vendored
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Set `skip_verify` to `true` to support locally signed certificates.

Sends DNS queries to a specific server and validates each answer against an expected value. Supports A, AAAA, and PTR record types. Each query produces a separate data source in the RRD, rendered as colored lines on the graph. The check succeeds only if all configured queries resolve and every answer matches its expected value.

PTR query names must be provided in reverse notation (e.g. `1.73.168.192.in-addr.arpa`). Expected PTR values may include or omit the trailing dot — both forms are accepted.
PTR query names must be provided in reverse notation (e.g. `1.168.168.192.in-addr.arpa`). Expected PTR values may include or omit the trailing dot — both forms are accepted.

| Option | Type | Default | Description |
| --------- | -------------- | ------------ | ---------------------------------------------------- |
Expand Down
26 changes: 13 additions & 13 deletions pkg/check/dns/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,9 @@ func (c *Check) Describe() check.Descriptor {
// Success requires every query to resolve and every answer to match its expected value.
// Each query's RTT is stored in microseconds keyed by the query name.
func (c *Check) Run(ctx context.Context) check.Result {
result := check.Result{
Timestamp: time.Now(),
Metrics: make(map[string]int64),
}

metrics := make(map[string]*int64, len(c.queries))
var lastErr error
succeeded := 0

for _, q := range c.queries {
msg := new(dns.Msg)
Expand All @@ -127,30 +124,33 @@ func (c *Check) Run(ctx context.Context) check.Result {
resp, rtt, err := c.client.ExchangeContext(ctx, msg, c.server)
if err != nil {
lastErr = fmt.Errorf("dns %s %s: %w", qtypeName(q.qtype), q.name, err)
metrics[q.resultKey] = nil
continue
}

if resp.Rcode != dns.RcodeSuccess {
lastErr = fmt.Errorf("dns %s %s: rcode %s", qtypeName(q.qtype), q.name, dns.RcodeToString[resp.Rcode])
metrics[q.resultKey] = nil
continue
}

if err := validateAnswer(resp.Answer, q.qtype, q.expect); err != nil {
lastErr = fmt.Errorf("dns %s %s: %w", qtypeName(q.qtype), q.name, err)
metrics[q.resultKey] = nil
continue
}

result.Metrics[q.resultKey] = rtt.Microseconds()
v := rtt.Microseconds()
metrics[q.resultKey] = &v
succeeded++
}

if len(result.Metrics) == len(c.queries) {
result.Success = true
} else {
result.Success = false
result.Err = lastErr
return check.Result{
Timestamp: time.Now(),
Success: succeeded == len(c.queries),
Err: lastErr,
Metrics: metrics,
}

return result
}

// validateAnswer checks that at least one RR in the answer section matches
Expand Down
56 changes: 31 additions & 25 deletions pkg/check/dns/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func TestNew_WithTimeoutZero(t *testing.T) {

func TestDescribe_SingleQuery(t *testing.T) {
queries := []queryConfig{
{name: "router.risse.tv", qtype: dns.TypeA, expect: "192.168.73.1", resultKey: "router.risse.tv", dsName: "q0"},
{name: "router.example.com", qtype: dns.TypeA, expect: "192.168.168.1", resultKey: "router.example.com", dsName: "q0"},
}
chk, err := New("127.0.0.1:53", queries)
if err != nil {
Expand All @@ -101,8 +101,8 @@ func TestDescribe_SingleQuery(t *testing.T) {
if m.DSName != "q0" {
t.Errorf("expected DSName 'q0', got %q", m.DSName)
}
if m.ResultKey != "router.risse.tv" {
t.Errorf("expected ResultKey 'router.risse.tv', got %q", m.ResultKey)
if m.ResultKey != "router.example.com" {
t.Errorf("expected ResultKey 'router.example.com', got %q", m.ResultKey)
}
if m.Unit != "ms" {
t.Errorf("expected Unit 'ms', got %q", m.Unit)
Expand Down Expand Up @@ -403,9 +403,9 @@ func TestNormalizeFQDN_WithoutDot(t *testing.T) {

func TestValidateAnswer_AMatch(t *testing.T) {
rrs := []dns.RR{
&dns.A{Hdr: dns.RR_Header{Rrtype: dns.TypeA}, A: net.ParseIP("192.168.73.1")},
&dns.A{Hdr: dns.RR_Header{Rrtype: dns.TypeA}, A: net.ParseIP("192.168.168.1")},
}
if err := validateAnswer(rrs, dns.TypeA, "192.168.73.1"); err != nil {
if err := validateAnswer(rrs, dns.TypeA, "192.168.168.1"); err != nil {
t.Errorf("expected match, got: %v", err)
}
}
Expand All @@ -414,7 +414,7 @@ func TestValidateAnswer_ANoMatch(t *testing.T) {
rrs := []dns.RR{
&dns.A{Hdr: dns.RR_Header{Rrtype: dns.TypeA}, A: net.ParseIP("10.0.0.1")},
}
if err := validateAnswer(rrs, dns.TypeA, "192.168.73.1"); err == nil {
if err := validateAnswer(rrs, dns.TypeA, "192.168.168.1"); err == nil {
t.Error("expected mismatch error")
}
}
Expand All @@ -430,18 +430,18 @@ func TestValidateAnswer_AAAAMatch(t *testing.T) {

func TestValidateAnswer_PTRMatchWithTrailingDot(t *testing.T) {
rrs := []dns.RR{
&dns.PTR{Hdr: dns.RR_Header{Rrtype: dns.TypePTR}, Ptr: "router.risse.tv."},
&dns.PTR{Hdr: dns.RR_Header{Rrtype: dns.TypePTR}, Ptr: "router.example.com."},
}
if err := validateAnswer(rrs, dns.TypePTR, "router.risse.tv."); err != nil {
if err := validateAnswer(rrs, dns.TypePTR, "router.example.com."); err != nil {
t.Errorf("expected match, got: %v", err)
}
}

func TestValidateAnswer_PTRMatchWithoutTrailingDot(t *testing.T) {
rrs := []dns.RR{
&dns.PTR{Hdr: dns.RR_Header{Rrtype: dns.TypePTR}, Ptr: "router.risse.tv."},
&dns.PTR{Hdr: dns.RR_Header{Rrtype: dns.TypePTR}, Ptr: "router.example.com."},
}
if err := validateAnswer(rrs, dns.TypePTR, "router.risse.tv"); err != nil {
if err := validateAnswer(rrs, dns.TypePTR, "router.example.com"); err != nil {
t.Errorf("expected match regardless of trailing dot, got: %v", err)
}
}
Expand Down Expand Up @@ -469,13 +469,13 @@ func TestRun_AQuery_Success(t *testing.T) {
m.SetReply(r)
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: net.ParseIP("192.168.73.1"),
A: net.ParseIP("192.168.168.1"),
})
_ = w.WriteMsg(m)
})

queries := []queryConfig{
{name: "router.risse.tv", qtype: dns.TypeA, expect: "192.168.73.1", resultKey: "router.risse.tv", dsName: "q0"},
{name: "router.example.com", qtype: dns.TypeA, expect: "192.168.168.1", resultKey: "router.example.com", dsName: "q0"},
}
chk, err := New(addr, queries)
if err != nil {
Expand All @@ -486,8 +486,8 @@ func TestRun_AQuery_Success(t *testing.T) {
if !result.Success {
t.Errorf("expected success, got failure: %v", result.Err)
}
if result.Metrics["router.risse.tv"] <= 0 {
t.Errorf("expected positive RTT, got %d", result.Metrics["router.risse.tv"])
if p := result.Metrics["router.example.com"]; p == nil || *p <= 0 {
t.Errorf("expected positive RTT, got %v", result.Metrics["router.example.com"])
}
if result.Timestamp.IsZero() {
t.Error("expected non-zero timestamp")
Expand All @@ -500,13 +500,13 @@ func TestRun_PTRQuery_Success(t *testing.T) {
m.SetReply(r)
m.Answer = append(m.Answer, &dns.PTR{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 60},
Ptr: "router.risse.tv.",
Ptr: "router.example.com.",
})
_ = w.WriteMsg(m)
})

queries := []queryConfig{
{name: "1.73.168.192.in-addr.arpa", qtype: dns.TypePTR, expect: "router.risse.tv.", resultKey: "1.73.168.192.in-addr.arpa", dsName: "q0"},
{name: "1.168.168.192.in-addr.arpa", qtype: dns.TypePTR, expect: "router.example.com.", resultKey: "1.168.168.192.in-addr.arpa", dsName: "q0"},
}
chk, err := New(addr, queries)
if err != nil {
Expand Down Expand Up @@ -552,20 +552,20 @@ func TestRun_MultipleQueries_AllSuccess(t *testing.T) {
case dns.TypeA:
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: net.ParseIP("192.168.73.1"),
A: net.ParseIP("192.168.168.1"),
})
case dns.TypePTR:
m.Answer = append(m.Answer, &dns.PTR{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 60},
Ptr: "router.risse.tv.",
Ptr: "router.example.com.",
})
}
_ = w.WriteMsg(m)
})

queries := []queryConfig{
{name: "router.risse.tv", qtype: dns.TypeA, expect: "192.168.73.1", resultKey: "router.risse.tv", dsName: "q0"},
{name: "1.73.168.192.in-addr.arpa", qtype: dns.TypePTR, expect: "router.risse.tv.", resultKey: "1.73.168.192.in-addr.arpa", dsName: "q1"},
{name: "router.example.com", qtype: dns.TypeA, expect: "192.168.168.1", resultKey: "router.example.com", dsName: "q0"},
{name: "1.168.168.192.in-addr.arpa", qtype: dns.TypePTR, expect: "router.example.com.", resultKey: "1.168.168.192.in-addr.arpa", dsName: "q1"},
}
chk, err := New(addr, queries)
if err != nil {
Expand Down Expand Up @@ -593,7 +593,7 @@ func TestRun_WrongAnswer_Failure(t *testing.T) {
})

queries := []queryConfig{
{name: "router.risse.tv", qtype: dns.TypeA, expect: "192.168.73.1", resultKey: "router.risse.tv", dsName: "q0"},
{name: "router.example.com", qtype: dns.TypeA, expect: "192.168.168.1", resultKey: "router.example.com", dsName: "q0"},
}
chk, err := New(addr, queries)
if err != nil {
Expand Down Expand Up @@ -640,7 +640,7 @@ func TestRun_PartialSuccess_Failure(t *testing.T) {
if call == 1 {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: net.ParseIP("192.168.73.1"),
A: net.ParseIP("192.168.168.1"),
})
} else {
m.Rcode = dns.RcodeNameError
Expand All @@ -649,7 +649,7 @@ func TestRun_PartialSuccess_Failure(t *testing.T) {
})

queries := []queryConfig{
{name: "router.risse.tv", qtype: dns.TypeA, expect: "192.168.73.1", resultKey: "router.risse.tv", dsName: "q0"},
{name: "router.example.com", qtype: dns.TypeA, expect: "192.168.168.1", resultKey: "router.example.com", dsName: "q0"},
{name: "missing.example.com", qtype: dns.TypeA, expect: "5.6.7.8", resultKey: "missing.example.com", dsName: "q1"},
}
chk, err := New(addr, queries)
Expand All @@ -661,8 +661,14 @@ func TestRun_PartialSuccess_Failure(t *testing.T) {
if result.Success {
t.Error("expected failure when one query fails")
}
if len(result.Metrics) != 1 {
t.Errorf("expected 1 successful metric, got %d", len(result.Metrics))
if len(result.Metrics) != 2 {
t.Errorf("expected 2 metrics (one nil, one non-nil), got %d", len(result.Metrics))
}
if result.Metrics["router.example.com"] == nil {
t.Error("expected non-nil metric for successful query")
}
if result.Metrics["missing.example.com"] != nil {
t.Error("expected nil metric for failed query")
}
}

Expand Down
25 changes: 12 additions & 13 deletions pkg/check/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,17 @@ func (c *Check) Describe() check.Descriptor {

// Run executes HTTP GET requests to all configured URLs and returns a Result.
func (c *Check) Run(ctx context.Context) check.Result {
result := check.Result{
Timestamp: time.Now(),
Metrics: make(map[string]int64),
}

metrics := make(map[string]*int64, len(c.urls))
var lastErr error
succeeded := 0

for _, url := range c.urls {
start := time.Now()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
lastErr = fmt.Errorf("failed to create request for %s: %w", url, err)
metrics[url] = nil
continue
}

Expand All @@ -140,21 +138,22 @@ func (c *Check) Run(ctx context.Context) check.Result {

if err != nil {
lastErr = fmt.Errorf("request to %s failed: %w", url, err)
metrics[url] = nil
continue
}
resp.Body.Close()

result.Metrics[url] = elapsed.Microseconds()
v := elapsed.Microseconds()
metrics[url] = &v
succeeded++
}

if len(result.Metrics) == len(c.urls) {
result.Success = true
} else {
result.Success = false
result.Err = lastErr
return check.Result{
Timestamp: time.Now(),
Success: succeeded == len(c.urls),
Err: lastErr,
Metrics: metrics,
}

return result
}

// Factory creates an HTTP Check from a config map.
Expand Down
4 changes: 2 additions & 2 deletions pkg/check/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ func TestRun_SingleURL_Success(t *testing.T) {
if !result.Success {
t.Errorf("expected success, got failure: %v", result.Err)
}
if result.Metrics[srv.URL] <= 0 {
t.Errorf("expected positive response time, got %d", result.Metrics[srv.URL])
if p := result.Metrics[srv.URL]; p == nil || *p <= 0 {
t.Errorf("expected positive response time, got %v", result.Metrics[srv.URL])
}
if result.Timestamp.IsZero() {
t.Error("expected non-zero timestamp")
Expand Down
19 changes: 8 additions & 11 deletions pkg/check/ping/ping.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ func (p *Ping) Describe() check.Descriptor {
// is stored in microseconds keyed by address string.
func (p *Ping) Run(ctx context.Context) check.Result {
now := time.Now()
metrics := make(map[string]int64)
metrics := make(map[string]*int64, len(p.addresses))
var lastErr error
succeeded := 0

timeoutSec := fmt.Sprintf("%.0f", p.timeout.Seconds())
count := strconv.Itoa(p.count)
Expand All @@ -146,29 +147,25 @@ func (p *Ping) Run(ctx context.Context) check.Result {

if err := cmd.Run(); err != nil {
lastErr = fmt.Errorf("ping %s: %w", a.address, err)
metrics[a.resultKey] = nil
continue
}

latency, err := parseOutput(out.String())
if err != nil {
lastErr = fmt.Errorf("ping %s: %w", a.address, err)
metrics[a.resultKey] = nil
continue
}

metrics[a.resultKey] = int64(latency.Microseconds())
}

if len(metrics) == len(p.addresses) {
return check.Result{
Timestamp: now,
Success: true,
Metrics: metrics,
}
v := int64(latency.Microseconds())
metrics[a.resultKey] = &v
succeeded++
}

return check.Result{
Timestamp: now,
Success: false,
Success: succeeded == len(p.addresses),
Err: lastErr,
Metrics: metrics,
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/check/ping/ping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ func TestRun_Localhost(t *testing.T) {
if !result.Success {
t.Skipf("ping failed (may not have permission): %v", result.Err)
}
if result.Metrics["127.0.0.1"] <= 0 {
t.Errorf("expected positive latency, got %d", result.Metrics["127.0.0.1"])
if p := result.Metrics["127.0.0.1"]; p == nil || *p <= 0 {
t.Errorf("expected positive latency, got %v", result.Metrics["127.0.0.1"])
}
}
6 changes: 3 additions & 3 deletions pkg/check/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ type Result struct {
Success bool

// Metrics holds named measurements from the check execution.
// For example, a ping check might set {"latency_us": 1234.0}.
// An empty or nil map is valid for checks that only report success/failure.
Metrics map[string]int64
// A nil pointer value for a key means the target was attempted but failed.
// An absent key or nil map means no measurement was attempted.
Metrics map[string]*int64

// Err holds any error encountered during check execution.
// A non-nil Err generally corresponds to Success being false,
Expand Down
8 changes: 5 additions & 3 deletions pkg/check/result_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ func TestResult_ZeroValue(t *testing.T) {
}

func TestResult_WithMetrics(t *testing.T) {
v := int64(12345)
r := Result{
Timestamp: time.Now(),
Success: true,
Metrics: map[string]int64{"latency_us": 12345},
Metrics: map[string]*int64{"latency_us": &v},
}
if !r.Success {
t.Error("expected success")
}
if v, ok := r.Metrics["latency_us"]; !ok || v != 12345 {
t.Errorf("expected latency_us=12345, got %v", v)
p, ok := r.Metrics["latency_us"]
if !ok || p == nil || *p != 12345 {
t.Errorf("expected latency_us=12345, got %v", p)
}
}
Loading