mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/ gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules (failsBeforeDown, latencyWarn, certDays, mute). P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip collectors, crt.sh subdomain discovery with daily sweep. P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login rate limit, public-route bypass) and multiple boards (boards table, sections.board_id, /api/boards CRUD, slug routing). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8973be3b41
commit
f3143af351
33 files changed
+2772
-126
No files matched your search
@@ -0,0 +1,30 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Embed renders a sandboxed iframe client-side — registered only so
|
||||
// /api/widgets/types lists it. The browser fetches the framed page, not the
|
||||
// server (remote content may be LAN-only or need the user's cookies).
|
||||
type embed struct{}
|
||||
|
||||
func init() { Register(embed{}) }
|
||||
|
||||
func (embed) Meta() Type {
|
||||
return Type{
|
||||
Type: "embed",
|
||||
Name: "Embed",
|
||||
Description: "Embed any page in a tile",
|
||||
Local: true,
|
||||
Fields: []Field{
|
||||
{Key: "url", Label: "URL", Placeholder: "https://…", Required: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (embed) Fetch(context.Context, json.RawMessage) (any, error) {
|
||||
return nil, fmt.Errorf("embed renders client-side")
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSON status: GET a URL, extract one value by dot/bracket path
|
||||
// ("data.status", "items[0].name"), render it in a tile. Covers the
|
||||
// "is API X alive / what does it report" case without per-service widgets.
|
||||
type jsonpath struct{}
|
||||
|
||||
func init() { Register(jsonpath{}) }
|
||||
|
||||
func (jsonpath) Meta() Type {
|
||||
return Type{
|
||||
Type: "jsonpath",
|
||||
Name: "JSON value",
|
||||
Description: "Extract one value from a JSON endpoint",
|
||||
Fields: []Field{
|
||||
{Key: "url", Label: "URL", Placeholder: "https://api…/status", Required: true},
|
||||
{Key: "path", Label: "Path", Placeholder: "data.status", Required: true},
|
||||
{Key: "header", Label: "Header", Placeholder: "Authorization: Bearer …"},
|
||||
{Key: "label", Label: "Label", Placeholder: "Status"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (jsonpath) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
|
||||
url := cfgStr(cfg, "url")
|
||||
path := cfgStr(cfg, "path")
|
||||
if url == "" || path == "" {
|
||||
return nil, fmt.Errorf("url and path required")
|
||||
}
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return nil, fmt.Errorf("url must be http(s)")
|
||||
}
|
||||
var headers map[string]string
|
||||
if h := cfgStr(cfg, "header"); h != "" {
|
||||
if k, v, ok := strings.Cut(h, ":"); ok {
|
||||
headers = map[string]string{strings.TrimSpace(k): strings.TrimSpace(v)}
|
||||
}
|
||||
}
|
||||
var doc any
|
||||
if err := getJSON(ctx, url, headers, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, ok := jsonPath(doc, path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("path %q not found", path)
|
||||
}
|
||||
return map[string]any{"value": v, "label": cfgStr(cfg, "label")}, nil
|
||||
}
|
||||
|
||||
// jsonPath walks doc along a path like "a.b[0].c". Segments split on dots;
|
||||
// a trailing [n] indexes into arrays. Returns false on any miss.
|
||||
func jsonPath(doc any, path string) (any, bool) {
|
||||
cur := doc
|
||||
for _, seg := range strings.Split(path, ".") {
|
||||
name, rest, _ := strings.Cut(seg, "[")
|
||||
if name != "" {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cur, ok = m[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
for rest != "" {
|
||||
idxStr, tail, _ := strings.Cut(rest, "]")
|
||||
i, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
arr, ok := cur.([]any)
|
||||
if !ok || i < 0 || i >= len(arr) {
|
||||
return nil, false
|
||||
}
|
||||
cur = arr[i]
|
||||
rest = strings.TrimPrefix(tail, "[")
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONPathWalk(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"data": map[string]any{
|
||||
"status": "ok",
|
||||
"items": []any{map[string]any{"name": "first"}, "second"},
|
||||
"nested": []any{[]any{float64(42)}},
|
||||
},
|
||||
}
|
||||
cases := []struct {
|
||||
path string
|
||||
want any
|
||||
ok bool
|
||||
}{
|
||||
{"data.status", "ok", true},
|
||||
{"data.items[0].name", "first", true},
|
||||
{"data.items[1]", "second", true},
|
||||
{"data.nested[0][0]", float64(42), true},
|
||||
{"data.missing", nil, false},
|
||||
{"data.items[9]", nil, false},
|
||||
{"data.items[-1]", nil, false},
|
||||
{"data.status.x", nil, false},
|
||||
{"data.items[x]", nil, false},
|
||||
{"data.status[0]", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := jsonPath(doc, tc.path)
|
||||
if ok != tc.ok || (ok && got != tc.want) {
|
||||
t.Errorf("path %q: got %v,%v want %v,%v", tc.path, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONPathFetch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer tok" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"degraded"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg, _ := json.Marshal(map[string]any{
|
||||
"url": srv.URL,
|
||||
"path": "data.status",
|
||||
"header": "Authorization: Bearer tok",
|
||||
"label": "State",
|
||||
})
|
||||
got, err := (jsonpath{}).Fetch(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["value"] != "degraded" || m["label"] != "State" {
|
||||
t.Fatalf("unexpected payload: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONPathFetchValidation(t *testing.T) {
|
||||
for _, cfg := range []string{
|
||||
`{"url":"","path":"a"}`,
|
||||
`{"url":"file:///etc/passwd","path":"a"}`,
|
||||
`{"url":"ftp://x","path":"a"}`,
|
||||
} {
|
||||
if _, err := (jsonpath{}).Fetch(context.Background(), json.RawMessage(cfg)); err == nil {
|
||||
t.Errorf("expected error for %s", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user