mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-29 00:01:24 +00:00
feat: external importers + uptime monitors (merge plan M1)
Two tracks from the Beszel/Domain-Locker merge plan in ROADMAP.md: - Importers: POST /api/import/external sniffs Homepage services.yml, Homarr JSON, and Dashy conf.yml into sections/items; SettingsDialog import now accepts all four formats. - Monitors (M1): monitors + heartbeats schema, pure checkers for http/tcp/ping/dns/keyword/json-query/push, scheduler on app ctx, CRUD + check-now + push ingest API, /monitors + /monitors/:id pages (wouter), monitor board widget, ServiceDialog monitor opt-in. 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
f428e721fe
commit
1ff1b29ae7
29 files changed
+3050
-98
No files matched your search
@@ -0,0 +1,322 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// diBase mirrors the frontend's dashboard-icons CDN root (ServiceDialog).
|
||||
const diBase = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"
|
||||
|
||||
// importExternal ingests a foreign dashboard config — Homepage services.yml,
|
||||
// Homarr JSON export, or Dashy conf.yml — and replaces the board with it.
|
||||
// Format is sniffed from the body; settings are left untouched.
|
||||
func (s *Server) importExternal(c *gin.Context) {
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 8<<20))
|
||||
if err != nil || len(body) == 0 {
|
||||
fail(c, http.StatusBadRequest, "empty or unreadable body")
|
||||
return
|
||||
}
|
||||
sections, err := parseExternal(body)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.replaceBoard(sections, nil); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
board, err := s.board()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, board)
|
||||
}
|
||||
|
||||
// parseExternal sniffs the config format: JSON decodes as Homarr; a YAML
|
||||
// sequence is Homepage's grouped services list; a YAML mapping carrying
|
||||
// `sections` is a Dashy conf.yml.
|
||||
func parseExternal(body []byte) ([]Section, error) {
|
||||
var j any
|
||||
if json.Unmarshal(body, &j) == nil {
|
||||
return parseHomarr(j)
|
||||
}
|
||||
var y any
|
||||
if err := yaml.Unmarshal(body, &y); err != nil {
|
||||
return nil, errors.New("unreadable file — expected JSON or YAML")
|
||||
}
|
||||
switch root := y.(type) {
|
||||
case []any:
|
||||
return parseHomepage(root)
|
||||
case map[string]any:
|
||||
if _, ok := root["sections"]; ok {
|
||||
return parseDashy(body)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unrecognized config — expected a Homepage services.yml, Homarr JSON export, or Dashy conf.yml")
|
||||
}
|
||||
|
||||
// ---------- Homepage ----------
|
||||
|
||||
// parseHomepage walks a services.yml: a sequence of single-key maps
|
||||
// (group name -> sequence of single-key service maps). Bare single-key
|
||||
// entries at the top level land in an "Imported" section.
|
||||
func parseHomepage(groups []any) ([]Section, error) {
|
||||
var secs []Section
|
||||
loose := Section{Name: "Imported"}
|
||||
for _, g := range groups {
|
||||
gm, ok := g.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for name, v := range gm {
|
||||
switch val := v.(type) {
|
||||
case []any:
|
||||
sec := Section{Name: name, Items: homepageItems(val)}
|
||||
if len(sec.Items) > 0 {
|
||||
secs = append(secs, sec)
|
||||
}
|
||||
case map[string]any:
|
||||
if it, ok := homepageService(name, val); ok {
|
||||
loose.Items = append(loose.Items, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(loose.Items) > 0 {
|
||||
secs = append([]Section{loose}, secs...)
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
return nil, errors.New("homepage: no importable services found")
|
||||
}
|
||||
return assignIDs(secs), nil
|
||||
}
|
||||
|
||||
func homepageItems(list []any) []Item {
|
||||
var items []Item
|
||||
for _, raw := range list {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for name, fields := range m {
|
||||
fm, _ := fields.(map[string]any)
|
||||
if it, ok := homepageService(name, fm); ok {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// homepageService maps one service entry. Entries without a reachable URL
|
||||
// (pure widget config, bare ping targets) are skipped rather than failing
|
||||
// the whole import.
|
||||
func homepageService(name string, fields map[string]any) (Item, bool) {
|
||||
u := strOf(fields, "href", "siteMonitor", "ping")
|
||||
if strings.TrimSpace(name) == "" || !validURL(u) {
|
||||
return Item{}, false
|
||||
}
|
||||
return importedItem(name, resolveIcon(strOf(fields, "icon"), u), u), true
|
||||
}
|
||||
|
||||
// ---------- Homarr ----------
|
||||
|
||||
// parseHomarr tolerates Homarr's shifting export shapes: `categories[].apps`,
|
||||
// a flat `apps` array grouped by category id, or board `sections[].items`.
|
||||
func parseHomarr(root any) ([]Section, error) {
|
||||
m, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("homarr: expected a JSON object")
|
||||
}
|
||||
var secs []Section
|
||||
seen := map[string]bool{}
|
||||
catNames := map[string]string{}
|
||||
|
||||
for _, c := range arrOf(m["categories"]) {
|
||||
cm, _ := c.(map[string]any)
|
||||
if name := strOf(cm, "name"); name != "" {
|
||||
catNames[strOf(cm, "id")] = name
|
||||
if s := sectionFromApps(name, arrOf(cm["apps"]), seen); len(s.Items) > 0 {
|
||||
secs = append(secs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apps := arrOf(m["apps"]); len(apps) > 0 {
|
||||
var order []string
|
||||
byCat := map[string][]any{}
|
||||
for _, a := range apps {
|
||||
am, _ := a.(map[string]any)
|
||||
cat := strOf(am, "categoryId", "category")
|
||||
if _, ok := byCat[cat]; !ok {
|
||||
order = append(order, cat)
|
||||
}
|
||||
byCat[cat] = append(byCat[cat], a)
|
||||
}
|
||||
for _, cat := range order {
|
||||
name := catNames[cat]
|
||||
if name == "" {
|
||||
name = "Imported"
|
||||
}
|
||||
if s := sectionFromApps(name, byCat[cat], seen); len(s.Items) > 0 {
|
||||
secs = append(secs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range arrOf(m["sections"]) {
|
||||
sm, _ := raw.(map[string]any)
|
||||
if s := sectionFromApps(strOf(sm, "name"), arrOf(sm["items"]), seen); len(s.Items) > 0 {
|
||||
secs = append(secs, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(secs) == 0 {
|
||||
return nil, errors.New("homarr: no apps found")
|
||||
}
|
||||
return assignIDs(secs), nil
|
||||
}
|
||||
|
||||
func sectionFromApps(name string, apps []any, seen map[string]bool) Section {
|
||||
sec := Section{Name: orDefault(name, "Imported")}
|
||||
for _, a := range apps {
|
||||
am, _ := a.(map[string]any)
|
||||
it, ok := homarrItem(am)
|
||||
if !ok || seen[it.Name+"|"+it.URLs[0].URL] {
|
||||
continue
|
||||
}
|
||||
seen[it.Name+"|"+it.URLs[0].URL] = true
|
||||
sec.Items = append(sec.Items, it)
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
func homarrItem(m map[string]any) (Item, bool) {
|
||||
name := strOf(m, "name", "title", "label")
|
||||
u := strOf(m, "url", "href", "appUrl", "externalUrl", "internalUrl")
|
||||
if name == "" || !validURL(u) {
|
||||
return Item{}, false
|
||||
}
|
||||
return importedItem(name, resolveIcon(strOf(m, "icon", "iconUrl"), u), u), true
|
||||
}
|
||||
|
||||
// ---------- Dashy ----------
|
||||
|
||||
type dashyConf struct {
|
||||
Sections []struct {
|
||||
Name string `yaml:"name"`
|
||||
Items []struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
} `yaml:"items"`
|
||||
} `yaml:"sections"`
|
||||
}
|
||||
|
||||
func parseDashy(body []byte) ([]Section, error) {
|
||||
var conf dashyConf
|
||||
if err := yaml.Unmarshal(body, &conf); err != nil {
|
||||
return nil, fmt.Errorf("dashy: malformed conf.yml")
|
||||
}
|
||||
var secs []Section
|
||||
for _, ds := range conf.Sections {
|
||||
sec := Section{Name: orDefault(ds.Name, "Imported")}
|
||||
for _, di := range ds.Items {
|
||||
if di.Title == "" || !validURL(di.URL) {
|
||||
continue
|
||||
}
|
||||
sec.Items = append(sec.Items, importedItem(di.Title, resolveIcon(di.Icon, di.URL), di.URL))
|
||||
}
|
||||
secs = append(secs, sec)
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
return nil, errors.New("dashy: no sections found")
|
||||
}
|
||||
return assignIDs(secs), nil
|
||||
}
|
||||
|
||||
// ---------- shared ----------
|
||||
|
||||
// assignIDs stamps fresh ids and 1024-spaced positions on an imported tree.
|
||||
func assignIDs(secs []Section) []Section {
|
||||
for si := range secs {
|
||||
secs[si].ID = newID("s")
|
||||
secs[si].Position = float64(si+1) * 1024
|
||||
for ii := range secs[si].Items {
|
||||
it := &secs[si].Items[ii]
|
||||
it.ID = newID("i")
|
||||
it.SectionID = secs[si].ID
|
||||
it.Kind = orDefault(it.Kind, "service")
|
||||
it.Position = float64(ii+1) * 1024
|
||||
for ui := range it.URLs {
|
||||
it.URLs[ui].ID = newID("u")
|
||||
it.URLs[ui].Position = float64(ui+1) * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
func importedItem(name, icon, u string) Item {
|
||||
return Item{Kind: "service", Name: name, Icon: icon, URLs: []URL{{URL: u}}}
|
||||
}
|
||||
|
||||
// resolveIcon maps a foreign icon reference to a usable URL: passthrough for
|
||||
// absolute URLs, favicon derivation for Dashy's `favicon`, the dashboard-icons
|
||||
// CDN for prefixed or bare slugs, and "" (letter tile) for local file paths
|
||||
// that only existed on the old dashboard.
|
||||
func resolveIcon(icon, itemURL string) string {
|
||||
icon = strings.TrimSpace(icon)
|
||||
switch {
|
||||
case icon == "":
|
||||
return ""
|
||||
case strings.HasPrefix(icon, "http://"), strings.HasPrefix(icon, "https://"):
|
||||
return icon
|
||||
case icon == "favicon", icon == "generalfavicon":
|
||||
return faviconOf(itemURL)
|
||||
case strings.ContainsAny(icon, "/.\\"):
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(icon)
|
||||
for _, p := range []string{"si-", "sh-", "hl-", "di-", "mdi-"} {
|
||||
if rest, ok := strings.CutPrefix(lower, p); ok {
|
||||
if rest == "" {
|
||||
return ""
|
||||
}
|
||||
return diBase + "/" + rest + ".png"
|
||||
}
|
||||
}
|
||||
return diBase + "/" + lower + ".png"
|
||||
}
|
||||
|
||||
func faviconOf(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/favicon.ico"
|
||||
}
|
||||
|
||||
func arrOf(v any) []any {
|
||||
a, _ := v.([]any)
|
||||
return a
|
||||
}
|
||||
|
||||
func strOf(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user