Compare commits

...

8 Commits

Author SHA1 Message Date
Svilen Markov 306ea6a4ba Use latest version of hero icons 2025-08-06 05:02:21 +07:00
Svilen Markov 3ebe47bb26 Reduce icon opacity 2025-08-06 04:47:19 +07:00
Svilen Markov 3331670823 Add basic auth support to custom API 2025-08-06 04:43:35 +07:00
Svilen Markov f0d0b9f1ba Add mock-response property 2025-08-06 04:35:55 +07:00
Svilen Markov 44804debd6 Add randomElement func 2025-08-06 04:33:00 +07:00
Svilen Markov bc5e2a4e20 Icon improvements
* Added support for hero icons
* Added support for specifying icon colors
* Added iconWithClass custom API func
* Refactored usage of icons across widget templates
2025-08-06 04:18:19 +07:00
Svilen Markov 52b1f89a7c Add withAllowInsecure func 2025-08-05 16:21:06 +07:00
Svilen Markov 148b6ad5a7 Add string formatting to newRequest func 2025-08-05 16:17:11 +07:00
11 changed files with 162 additions and 90 deletions

@ -15,6 +15,7 @@ import (
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
var inStringPropertyPattern = regexp.MustCompile(`(?m)([a-zA-Z]+)\[(.*?)\]`)
const (
hslHueMax = 360
@ -133,9 +134,30 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
type customIconField struct {
URL template.URL
Color string
AutoInvert bool
}
func (h *customIconField) Elem() template.HTML {
return h.ElemWithClass("")
}
func (h *customIconField) ElemWithClass(class string) template.HTML {
if h.AutoInvert && h.Color == "" {
class = "flat-icon " + class
}
if h.Color != "" {
return template.HTML(
`<div class="icon colored-icon ` + class + `" style="--icon-color: ` + h.Color + `; --icon-url: url('` + string(h.URL) + `')"></div>`,
)
}
return template.HTML(
`<img class="icon ` + class + `" src="` + string(h.URL) + `" alt="" loading="lazy">`,
)
}
func newCustomIconField(value string) customIconField {
const autoInvertPrefix = "auto-invert "
field := customIconField{}
@ -145,6 +167,25 @@ func newCustomIconField(value string) customIconField {
value = strings.TrimPrefix(value, autoInvertPrefix)
}
value, properties := parseInStringProperties(value)
if color, ok := properties["color"]; ok {
switch color {
case "primary":
color = "var(--color-primary)"
case "positive":
color = "var(--color-positive)"
case "negative":
color = "var(--color-negative)"
case "base":
color = "var(--color-text-base)"
case "subdue":
color = "var(--color-text-subdue)"
}
field.Color = color
}
prefix, icon, found := strings.Cut(value, ":")
if !found {
field.URL = template.URL(value)
@ -172,6 +213,9 @@ func newCustomIconField(value string) customIconField {
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
case "sh":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
case "hi":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/heroicons@latest/24/" + basename + ".svg")
default:
field.URL = template.URL(value)
}
@ -189,6 +233,23 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
return nil
}
func parseInStringProperties(value string) (string, map[string]string) {
properties := make(map[string]string)
value = inStringPropertyPattern.ReplaceAllStringFunc(value, func(match string) string {
matches := inStringPropertyPattern.FindStringSubmatch(match)
if len(matches) != 3 {
return ""
}
properties[matches[1]] = matches[2]
return ""
})
return strings.TrimSpace(value), properties
}
type proxyOptionsField struct {
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`

@ -376,7 +376,27 @@ details[open] .summary::after {
gap: 0.5rem;
}
:root:not([data-scheme=light]) .flat-icon {
.icon {
object-fit: contain;
}
.icon:not(.colored-icon) {
opacity: 0.8;
filter: grayscale(0.2);
transition: opacity 0.2s, filter 0.2s;
}
.icon-parent:hover .icon {
opacity: 1;
filter: none;
}
.colored-icon {
mask: var(--icon-url) no-repeat center / contain;
background-color: var(--icon-color);
}
:root:not([data-scheme=light]) .flat-icon:not(.colored-icon) {
filter: invert(1);
}
@ -459,7 +479,6 @@ details[open] .summary::after {
filter: none;
}
.hide-scrollbars {
scrollbar-width: none;
}
@ -547,6 +566,13 @@ details[open] .summary::after {
visibility: hidden;
}
.square-18 { width: 1.8rem; height: 1.8rem; }
.square-20 { width: 2rem; height: 2rem; }
.square-27 { width: 2.7rem; height: 2.7rem; }
.square-30 { width: 3rem; height: 3rem; }
.square-32 { width: 3.2rem; height: 3.2rem; }
.square-40 { width: 4rem; height: 4rem; }
.cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; }
@ -588,8 +614,11 @@ details[open] .summary::after {
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; }
.gap-30 { gap: 3rem; }
.gap-35 { gap: 3.5rem; }
.gap-40 { gap: 4rem; }
.gap-45 { gap: 4.5rem; }
.gap-50 { gap: 5rem; }
.gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-top-3 { margin-top: 0.3rem; }

@ -20,12 +20,5 @@
background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius);
padding: 0.5rem;
opacity: 0.7;
flex-shrink: 0;
}
.bookmarks-icon {
width: 20px;
height: 20px;
opacity: 0.8;
}

@ -1,26 +0,0 @@
.docker-container-icon {
display: block;
filter: grayscale(0.4);
object-fit: contain;
aspect-ratio: 1 / 1;
width: 2.7rem;
opacity: 0.8;
transition: filter 0.3s, opacity 0.3s;
}
.docker-container-icon.flat-icon {
opacity: 0.7;
}
.docker-container:hover .docker-container-icon {
opacity: 1;
}
.docker-container:hover .docker-container-icon:not(.flat-icon) {
filter: grayscale(0);
}
.docker-container-status-icon {
width: 2rem;
height: 2rem;
}

@ -1,36 +0,0 @@
.monitor-site-icon {
display: block;
opacity: 0.8;
filter: grayscale(0.4);
object-fit: contain;
aspect-ratio: 1 / 1;
width: 3.2rem;
position: relative;
top: -0.1rem;
transition: filter 0.3s, opacity 0.3s;
}
.monitor-site-icon.flat-icon {
opacity: 0.7;
}
.monitor-site:hover .monitor-site-icon {
opacity: 1;
}
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
filter: grayscale(0);
}
.monitor-site-status-icon {
flex-shrink: 0;
margin-left: auto;
width: 2rem;
height: 2rem;
}
.monitor-site-status-icon-compact {
width: 1.8rem;
height: 1.8rem;
flex-shrink: 0;
}

@ -2,10 +2,8 @@
@import "widget-calendar.css";
@import "widget-clock.css";
@import "widget-dns-stats.css";
@import "widget-docker-containers.css";
@import "widget-group.css";
@import "widget-markets.css";
@import "widget-monitor.css";
@import "widget-reddit.css";
@import "widget-releases.css";
@import "widget-rss.css";

@ -10,10 +10,10 @@
<ul class="list list-gap-2">
{{- range .Links }}
<li>
<div class="flex items-center gap-10">
<div class="flex items-center gap-10 icon-parent">
{{- if ne "" .Icon.URL }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ .Icon.ElemWithClass "square-20" }}
</div>
{{- end }}
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>

@ -3,9 +3,9 @@
{{- define "widget-content" }}
<ul class="dynamic-columns list-gap-20 list-with-separator">
{{- range .Containers }}
<li class="docker-container flex items-center gap-15">
<li class="flex items-center gap-15 icon-parent">
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
<img class="docker-container-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ .Icon.ElemWithClass "square-27" }}
<div data-popover-html>
<div class="color-highlight text-truncate block">{{ .Image }}</div>
<div>{{ .StateText }}</div>
@ -47,19 +47,19 @@
{{- define "state-icon" }}
{{- if eq . "ok" }}
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<svg class="square-20" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
{{- else if eq . "warn" }}
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<svg class="square-20" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{- else if eq . "paused" }}
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<svg class="square-20" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
</svg>
{{- else }}
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<svg class="square-20" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{- end }}

@ -24,13 +24,13 @@
<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
<div class="square-18 shrink-0" title="{{ .Status.Code }}">
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
<div class="square-18 shrink-0" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>

@ -5,7 +5,7 @@
<ul class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Sites }}
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
<div class="monitor-site flex items-center gap-15">
<div class="monitor-site flex items-center gap-15 icon-parent">
{{ template "site" . }}
</div>
{{ end }}
@ -22,7 +22,7 @@
{{ define "site" }}
{{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ .Icon.ElemWithClass "square-32" }}
{{ end }}
<div class="grow min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
@ -38,13 +38,13 @@
</ul>
</div>
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon">
<div class="square-20 shrink-0 margin-left-auto">
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon">
<div class="square-20 shrink-0 margin-left-auto">
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>

@ -11,6 +11,7 @@ import (
"iter"
"log/slog"
"math"
"math/rand"
"net/http"
"regexp"
"sort"
@ -33,9 +34,14 @@ type CustomAPIRequest struct {
Method string `yaml:"method"`
BodyType string `yaml:"body-type"`
Body any `yaml:"body"`
MockResponse string `yaml:"mock-response"`
SkipJSONValidation bool `yaml:"skip-json-validation"`
bodyReader io.ReadSeeker `yaml:"-"`
httpRequest *http.Request `yaml:"-"`
BasicAuth struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
} `yaml:"basic-auth"`
bodyReader io.ReadSeeker `yaml:"-"`
httpRequest *http.Request `yaml:"-"`
}
type customAPIWidget struct {
@ -188,6 +194,10 @@ func (req *CustomAPIRequest) initialize() error {
httpReq.Header.Add(key, value)
}
if req.BasicAuth.Username != "" || req.BasicAuth.Password != "" {
httpReq.SetBasicAuth(req.BasicAuth.Username, req.BasicAuth.Password)
}
req.httpRequest = httpReq
return nil
@ -230,6 +240,15 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData
}
func fetchCustomAPIResponse(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
if req != nil && req.MockResponse != "" {
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Parse(req.MockResponse)},
Response: &http.Response{
StatusCode: http.StatusOK,
},
}, nil
}
if req == nil || req.URL == "" {
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Result{}},
@ -515,6 +534,10 @@ var customAPITemplateFuncs = func() template.FuncMap {
}
return a % b
},
"iconWithClass": func(name, class string) template.HTML {
i := newCustomIconField(name)
return i.ElemWithClass(class)
},
"now": func() time.Time {
return time.Now()
},
@ -651,7 +674,11 @@ var customAPITemplateFuncs = func() template.FuncMap {
}
return out
},
"newRequest": func(url string) *CustomAPIRequest {
"newRequest": func(url string, params ...any) *CustomAPIRequest {
if len(params) > 0 {
url = fmt.Sprintf(url, params...)
}
return &CustomAPIRequest{
URL: url,
}
@ -675,6 +702,25 @@ var customAPITemplateFuncs = func() template.FuncMap {
req.BodyType = "string"
return req
},
"withAllowInsecure": func(val any, req *CustomAPIRequest) *CustomAPIRequest {
switch v := val.(type) {
case bool:
req.AllowInsecure = v
case string:
if strings.ToLower(v) == "true" {
req.AllowInsecure = true
}
default:
slog.Warn("withAllowInsecure called with non-boolean value, must be string or bool", "value", v)
}
return req
},
"withBasicAuth": func(username, password string, req *CustomAPIRequest) *CustomAPIRequest {
req.BasicAuth.Username = username
req.BasicAuth.Password = password
return req
},
"getResponse": func(req *CustomAPIRequest) *customAPIResponseData {
err := req.initialize()
if err != nil {
@ -694,6 +740,13 @@ var customAPITemplateFuncs = func() template.FuncMap {
return data
},
"randomElement": func(arr []decoratedGJSONResult) *decoratedGJSONResult {
if len(arr) == 0 {
return &decoratedGJSONResult{gjson.Result{}}
}
return &arr[rand.Intn(len(arr))]
},
}
for key, value := range globalTemplateFunctions {