Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ handler := monitor.New(mux, monitor.Config{
Title: "My App Monitor",
Description: "Live production service metrics.",
Footer: "Copyright 2026 Example Inc.",
FaviconURL: "/assets/favicon.svg",
DefaultLanguage: "en",
DefaultTheme: "dark",
Background: "solid",
Expand All @@ -270,6 +271,7 @@ Defaults:
| `Title` | `Monitor` | HTML page title and heading. |
| `Description` | `Live process, runtime, system, and HTTP metrics for this Go service.` | Short visible description below the header. |
| `Footer` | `Powered by github.com/gofurry/monitor - MIT License.` | Footer text for copyright, ownership, or license notes. |
| `FaviconURL` | built-in favicon | Overrides the dashboard favicon with a root-relative path or absolute HTTP(S) URL. Empty or invalid values use the built-in favicon. |
| `DefaultLanguage` | `en` | Initial UI language when no browser preference is saved. Supported values: `en`, `zh-CN`. |
| `DefaultTheme` | `dark` | Initial UI theme when no browser preference is saved. Supported values: `light`, `dark`. |
| `Background` | `solid` | HTML page background. Supported values: `solid`, `grid`. |
Expand All @@ -281,6 +283,18 @@ Defaults:

Requests to `Path` are always excluded from `http.total_requests`; the monitor page and its JSON polling do not inflate the business request count. `IgnoreRequest` is for other non-business traffic, such as load balancer probes or health checks. Ignored requests are still served by your handler.

### Dashboard favicon

The dashboard includes an embedded favicon by default. Set `FaviconURL` to use a favicon served by your application or a remote HTTP(S) URL:

```go
handler := monitor.New(mux, monitor.Config{
FaviconURL: "/assets/favicon.svg",
})
```

Filesystem paths such as `./favicon.ico` are not supported directly. Serve the file through your application first, then configure its URL. A same-origin URL is preferred because a remote favicon causes every dashboard visitor's browser to contact that host.

## Best Practice

`monitor` does not persist metrics, logs, traces, or chart history. It shows the current process, current host, Go runtime, and requests handled by this middleware instance.
Expand Down
27 changes: 27 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package monitor

import (
"net/http"
"net/url"
"strings"
"time"
)

Expand Down Expand Up @@ -37,6 +39,11 @@ type Config struct {
// copyright, ownership, or license text.
Footer string

// FaviconURL overrides the built-in dashboard favicon. It supports a
// root-relative path or an absolute HTTP(S) URL. Empty or invalid values use
// the built-in favicon.
FaviconURL string

// DefaultLanguage controls the initial HTML UI language when the browser has
// no saved monitor language preference. Supported values are "en" and
// "zh-CN". Empty or unsupported values use "en".
Expand Down Expand Up @@ -109,6 +116,7 @@ func applyConfig(configs []Config) Config {
if cfg.Footer == "" {
cfg.Footer = defaultFooter
}
cfg.FaviconURL = normalizeFaviconURL(cfg.FaviconURL)
if !isSupportedLanguage(cfg.DefaultLanguage) {
cfg.DefaultLanguage = defaultLanguage
}
Expand All @@ -130,6 +138,25 @@ func applyConfig(configs []Config) Config {
return cfg
}

func normalizeFaviconURL(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ""
}

parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") && !strings.HasPrefix(rawURL, "/\\") && parsed.Scheme == "" && parsed.Host == "" {
return rawURL
}
if (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) && parsed.Host != "" && parsed.User == nil {
return rawURL
}
return ""
}

func isSupportedLanguage(lang string) bool {
return lang == "en" || lang == "zh-CN"
}
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/v1.1.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# v1.1.1 Release Notes

- Add a built-in dashboard favicon with `Config.FaviconURL` support for root-relative and HTTP(S) URLs.
- Invalid or empty favicon URLs safely fall back to the built-in icon.
15 changes: 15 additions & 0 deletions html.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ package monitor
import (
"bytes"
_ "embed"
"encoding/base64"
"encoding/json"
"html/template"
"time"
)

const defaultMonitorFaviconSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<path d="M8 34h13l6-17 11 32 7-20 4 5h7" fill="none" stroke="#67e8f9" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`

var defaultMonitorFaviconURL = "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(defaultMonitorFaviconSVG))

//go:embed internal/ui/page.html
var monitorPageHTML string

Expand All @@ -23,6 +31,7 @@ type monitorPageData struct {
Title string
Description string
Footer string
FaviconURL any
CSS template.CSS
JS template.JS
ConfigJSON template.JS
Expand All @@ -41,6 +50,11 @@ type monitorClientConfig struct {
}

func renderHTML(cfg Config) string {
var faviconURL any = cfg.FaviconURL
if cfg.FaviconURL == "" {
faviconURL = template.URL(defaultMonitorFaviconURL) // #nosec G203 -- this is the package-owned embedded favicon.
}

refreshMS := maxInt64(int64(cfg.Refresh/time.Millisecond), 250)
configJSON, _ := json.Marshal(monitorClientConfig{
RefreshMS: refreshMS,
Expand All @@ -53,6 +67,7 @@ func renderHTML(cfg Config) string {
Title: cfg.Title,
Description: cfg.Description,
Footer: cfg.Footer,
FaviconURL: faviconURL,
CSS: template.CSS(monitorStyleCSS),
JS: template.JS(monitorAppJS),
ConfigJSON: template.JS(configJSON),
Expand Down
1 change: 1 addition & 0 deletions internal/ui/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ .Description }}">
<link rel="icon" href="{{ .FaviconURL }}">
<title>{{ .Title }}</title>
<style>{{ .CSS }}</style>
</head>
Expand Down
65 changes: 65 additions & 0 deletions monitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package monitor

import (
"encoding/json"
"html"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -366,6 +367,50 @@ func TestMonitorHTMLEscapesConfiguredContent(t *testing.T) {
}
}

func TestMonitorHTMLFavicon(t *testing.T) {
tests := []struct {
name string
faviconURL string
want string
}{
{
name: "built-in default",
want: `rel="icon" href="data:image/svg+xml;base64,`,
},
{
name: "root-relative URL",
faviconURL: "/assets/favicon.svg",
want: `rel="icon" href="/assets/favicon.svg"`,
},
{
name: "HTTPS URL",
faviconURL: "https://cdn.example.com/favicon.ico",
want: `rel="icon" href="https://cdn.example.com/favicon.ico"`,
},
{
name: "invalid URL falls back to built-in favicon",
faviconURL: "javascript:alert(1)",
want: `rel="icon" href="data:image/svg+xml;base64,`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := NewMonitor(http.NotFoundHandler(), Config{
FaviconURL: tt.faviconURL,
Refresh: time.Hour,
})
defer m.Stop()

rec := httptest.NewRecorder()
m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/monitor", nil))
if body := html.UnescapeString(rec.Body.String()); !strings.Contains(body, tt.want) {
t.Fatalf("HTML body does not contain %q", tt.want)
}
})
}
}

func TestMonitorHTMLIncludesEnhancedUI(t *testing.T) {
m := NewMonitor(http.NotFoundHandler(), Config{Refresh: time.Hour})
defer m.Stop()
Expand Down Expand Up @@ -632,6 +677,26 @@ func TestConfigDefaultsAndPathNormalization(t *testing.T) {
}
}

func TestConfigNormalizesFaviconURL(t *testing.T) {
valid := applyConfig([]Config{{FaviconURL: " /assets/favicon.svg "}})
if valid.FaviconURL != "/assets/favicon.svg" {
t.Fatalf("favicon URL = %q, want /assets/favicon.svg", valid.FaviconURL)
}

for _, faviconURL := range []string{
"./favicon.ico",
"//cdn.example.com/favicon.ico",
"data:image/svg+xml;base64,PHN2Zz4=",
"javascript:alert(1)",
"https://user@example.com/favicon.ico",
} {
cfg := applyConfig([]Config{{FaviconURL: faviconURL}})
if cfg.FaviconURL != "" {
t.Errorf("favicon URL %q = %q, want empty", faviconURL, cfg.FaviconURL)
}
}
}

func TestConfigValidatesUIDefaults(t *testing.T) {
valid := applyConfig([]Config{
{
Expand Down