Add in-app edit UI for dashboard config
A web editor at /edit for managing pages and widgets without hand-editing YAML:
- Page list and per-page widget views with breadcrumb navigation
- Add/delete pages, add/delete/reorder widgets within a column
- Per-widget textarea editor with curated starter templates, widget-specific
help (example, full field reference, link to upstream docs), and inline
error rendering that preserves the user's edits on validation failure
- Pre-save validation via newConfigFromYAML; atomic temp+rename writes with
.bak backup; YAML edits go through yaml.Node so comments and unresolved
${env:X}/${secret:X} tokens survive round-trips
- Auth gate: editing requires auth.users to be configured, or
admin.allow-without-auth: true as an explicit opt-in for trusted networks
- Pencil button on the dashboard near the theme picker for quick access
- Admin views read fresh from disk so the list updates immediately after
saves rather than lagging the fsnotify watcher
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6c5b7a3f4c
commit
2e198a19b4
@@ -0,0 +1,537 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html")
|
||||
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||
)
|
||||
|
||||
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||
var allWidgetTypes = []string{
|
||||
"bookmarks", "calendar", "change-detection", "clock", "custom-api",
|
||||
"dns-stats", "docker-containers", "extension", "group", "hacker-news",
|
||||
"html", "iframe", "lobsters", "markets", "monitor", "reddit",
|
||||
"releases", "repository", "rss", "search", "server-stats",
|
||||
"split-column", "to-do", "twitch-channels", "twitch-top-games",
|
||||
"videos", "weather",
|
||||
}
|
||||
|
||||
// widgetFieldReferences caches the marshaled YAML of an empty instance of
|
||||
// each widget type, used to show users what fields are available when
|
||||
// editing. Computed once at startup.
|
||||
var widgetFieldReferences = func() map[string]string {
|
||||
out := make(map[string]string, len(allWidgetTypes))
|
||||
for _, t := range allWidgetTypes {
|
||||
w, err := newWidget(t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b, err := yaml.Marshal(w)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[t] = string(b)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// widgetDocsAnchors maps a widget type to its anchor in upstream Glance docs.
|
||||
// Anchors are GitHub-rendered from the headings in docs/configuration.md.
|
||||
var widgetDocsAnchors = map[string]string{
|
||||
"bookmarks": "bookmarks",
|
||||
"calendar": "calendar",
|
||||
"calendar-legacy": "calendar-legacy",
|
||||
"change-detection": "changedetectionio",
|
||||
"clock": "clock",
|
||||
"custom-api": "custom-api",
|
||||
"dns-stats": "dns-stats",
|
||||
"docker-containers": "docker-containers",
|
||||
"extension": "extension",
|
||||
"group": "group",
|
||||
"hacker-news": "hacker-news",
|
||||
"lobsters": "lobsters",
|
||||
"markets": "markets",
|
||||
"monitor": "monitor",
|
||||
"reddit": "reddit",
|
||||
"releases": "releases",
|
||||
"repository": "repository",
|
||||
"rss": "rss",
|
||||
"search": "search-widget",
|
||||
"server-stats": "server-stats",
|
||||
"split-column": "split-column",
|
||||
"to-do": "todo",
|
||||
"twitch-channels": "twitch-channels",
|
||||
"twitch-top-games": "twitch-top-games",
|
||||
"videos": "videos",
|
||||
"weather": "weather",
|
||||
}
|
||||
|
||||
func widgetDocsURL(widgetType string) string {
|
||||
anchor, ok := widgetDocsAnchors[widgetType]
|
||||
if !ok {
|
||||
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#widgets"
|
||||
}
|
||||
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#" + anchor
|
||||
}
|
||||
|
||||
// widgetStarters provides hand-curated minimal-but-meaningful YAML for each
|
||||
// widget type, drawn from the upstream docs. Used to pre-fill the editor
|
||||
// when adding a new widget so users see a working shape they can adapt.
|
||||
var widgetStarters = map[string]string{
|
||||
"bookmarks": `type: bookmarks
|
||||
groups:
|
||||
- title: Sites
|
||||
links:
|
||||
- title: Glance
|
||||
url: https://github.com/glanceapp/glance
|
||||
`,
|
||||
"calendar": "type: calendar\n",
|
||||
"calendar-legacy": "type: calendar-legacy\n",
|
||||
"change-detection": `type: change-detection
|
||||
instance-url: https://changedetection.example.com/
|
||||
token: ${CHANGE_DETECTION_TOKEN}
|
||||
`,
|
||||
"clock": "type: clock\n",
|
||||
"custom-api": `type: custom-api
|
||||
url: https://api.example.com/data
|
||||
template: |
|
||||
<p>Replace with a Go template that renders the JSON response.</p>
|
||||
`,
|
||||
"dns-stats": `type: dns-stats
|
||||
service: pihole
|
||||
url: http://pi.hole
|
||||
allow-insecure: true
|
||||
username: admin
|
||||
password: ${DNS_PASSWORD}
|
||||
`,
|
||||
"docker-containers": "type: docker-containers\n",
|
||||
"extension": `type: extension
|
||||
url: https://example.com/extension
|
||||
`,
|
||||
"group": `type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
`,
|
||||
"hacker-news": "type: hacker-news\n",
|
||||
"html": `type: html
|
||||
source: |
|
||||
<p>Hello from a custom HTML widget.</p>
|
||||
`,
|
||||
"iframe": `type: iframe
|
||||
source: https://example.com
|
||||
`,
|
||||
"lobsters": "type: lobsters\n",
|
||||
"markets": `type: markets
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
`,
|
||||
"monitor": `type: monitor
|
||||
title: Services
|
||||
sites:
|
||||
- title: Example
|
||||
url: https://example.com
|
||||
`,
|
||||
"reddit": `type: reddit
|
||||
subreddit: technology
|
||||
`,
|
||||
"releases": `type: releases
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
`,
|
||||
"repository": `type: repository
|
||||
repository: glanceapp/glance
|
||||
`,
|
||||
"rss": `type: rss
|
||||
feeds:
|
||||
- url: https://www.theverge.com/rss/index.xml
|
||||
title: The Verge
|
||||
`,
|
||||
"search": "type: search\n",
|
||||
"server-stats": "type: server-stats\n",
|
||||
"split-column": `type: split-column
|
||||
widgets:
|
||||
- type: clock
|
||||
- type: search
|
||||
`,
|
||||
"to-do": "type: to-do\n",
|
||||
"twitch-channels": `type: twitch-channels
|
||||
channels:
|
||||
- shroud
|
||||
`,
|
||||
"twitch-top-games": "type: twitch-top-games\n",
|
||||
"videos": `type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw
|
||||
`,
|
||||
"weather": `type: weather
|
||||
location: London, GB
|
||||
`,
|
||||
}
|
||||
|
||||
func widgetStarterYAML(widgetType string) string {
|
||||
if s, ok := widgetStarters[widgetType]; ok {
|
||||
return s
|
||||
}
|
||||
return "type: " + widgetType + "\n"
|
||||
}
|
||||
|
||||
type adminWidgetView struct {
|
||||
ColumnIndex int
|
||||
WidgetIndex int
|
||||
ID uint64
|
||||
Title string
|
||||
Type string
|
||||
IsFirst bool
|
||||
IsLast bool
|
||||
}
|
||||
|
||||
type adminColumnView struct {
|
||||
Index int
|
||||
Size string
|
||||
Widgets []adminWidgetView
|
||||
}
|
||||
|
||||
type adminPageSummary struct {
|
||||
Title string
|
||||
Slug string
|
||||
WidgetCount int
|
||||
}
|
||||
|
||||
type adminPageDetail struct {
|
||||
Title string
|
||||
Slug string
|
||||
HeadWidgets []adminWidgetView
|
||||
Columns []adminColumnView
|
||||
}
|
||||
|
||||
type adminTemplateData struct {
|
||||
App *application
|
||||
Request templateRequestData
|
||||
|
||||
Pages []adminPageSummary
|
||||
Page *adminPageDetail
|
||||
Widget *adminWidgetView
|
||||
WidgetTypes []string
|
||||
WidgetYAML string
|
||||
LoadError string
|
||||
FieldReference string
|
||||
WidgetExample string
|
||||
DocsURL string
|
||||
IsNew bool
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
summaries := make([]adminPageSummary, 0, len(pages))
|
||||
for p := range pages {
|
||||
page := &pages[p]
|
||||
count := len(page.HeadWidgets)
|
||||
for c := range page.Columns {
|
||||
count += len(page.Columns[c].Widgets)
|
||||
}
|
||||
summaries = append(summaries, adminPageSummary{
|
||||
Title: page.Title,
|
||||
Slug: page.Slug,
|
||||
WidgetCount: count,
|
||||
})
|
||||
}
|
||||
|
||||
data := adminTemplateData{App: a, Pages: summaries}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminIndexTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
detail := adminPageDetail{Title: page.Title, Slug: page.Slug}
|
||||
|
||||
for i, widget := range page.HeadWidgets {
|
||||
detail.HeadWidgets = append(detail.HeadWidgets, adminWidgetView{
|
||||
ColumnIndex: -1,
|
||||
WidgetIndex: i,
|
||||
ID: widget.GetID(),
|
||||
Title: widget.GetTitle(),
|
||||
Type: widget.GetType(),
|
||||
})
|
||||
}
|
||||
|
||||
for c := range page.Columns {
|
||||
col := &page.Columns[c]
|
||||
view := adminColumnView{Index: c, Size: col.Size}
|
||||
last := len(col.Widgets) - 1
|
||||
for w, widget := range col.Widgets {
|
||||
view.Widgets = append(view.Widgets, adminWidgetView{
|
||||
ColumnIndex: c,
|
||||
WidgetIndex: w,
|
||||
ID: widget.GetID(),
|
||||
Title: widget.GetTitle(),
|
||||
Type: widget.GetType(),
|
||||
IsFirst: w == 0,
|
||||
IsLast: w == last,
|
||||
})
|
||||
}
|
||||
detail.Columns = append(detail.Columns, view)
|
||||
}
|
||||
|
||||
data := adminTemplateData{App: a, Page: &detail, WidgetTypes: allWidgetTypes}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminPageTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, pageIdx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
col, err := strconv.Atoi(r.PathValue("col"))
|
||||
if err != nil {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||
if err != nil {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var widget widget
|
||||
if col == -1 {
|
||||
if idx < 0 || idx >= len(page.HeadWidgets) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
widget = page.HeadWidgets[idx]
|
||||
} else {
|
||||
if col < 0 || col >= len(page.Columns) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
column := &page.Columns[col]
|
||||
if idx < 0 || idx >= len(column.Widgets) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
widget = column.Widgets[idx]
|
||||
}
|
||||
|
||||
yamlText, loadErr := loadWidgetYAML(a.ConfigPath, pageIdx, col, idx)
|
||||
|
||||
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||
pageTitle: page.Title,
|
||||
pageSlug: page.Slug,
|
||||
col: col,
|
||||
idx: idx,
|
||||
widgetType: widget.GetType(),
|
||||
widgetTitle: widget.GetTitle(),
|
||||
yamlText: yamlText,
|
||||
loadError: loadErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
col, err := strconv.Atoi(r.PathValue("col"))
|
||||
if err != nil || col < 0 || col >= len(page.Columns) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
widgetType := r.URL.Query().Get("type")
|
||||
if _, err := newWidget(widgetType); err != nil {
|
||||
adminError(w, http.StatusBadRequest, "unknown widget type: "+widgetType)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||
pageTitle: page.Title,
|
||||
pageSlug: page.Slug,
|
||||
isNew: true,
|
||||
col: col,
|
||||
widgetType: widgetType,
|
||||
yamlText: widgetStarterYAML(widgetType),
|
||||
})
|
||||
}
|
||||
|
||||
type editorRenderInput struct {
|
||||
pageTitle string
|
||||
pageSlug string
|
||||
isNew bool
|
||||
col int
|
||||
idx int
|
||||
widgetType string
|
||||
widgetTitle string
|
||||
yamlText string
|
||||
loadError string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (a *application) renderWidgetEditor(w http.ResponseWriter, r *http.Request, in editorRenderInput) {
|
||||
view := adminWidgetView{
|
||||
ColumnIndex: in.col,
|
||||
WidgetIndex: in.idx,
|
||||
Title: in.widgetTitle,
|
||||
Type: in.widgetType,
|
||||
}
|
||||
detail := adminPageDetail{Title: in.pageTitle, Slug: in.pageSlug}
|
||||
|
||||
data := adminTemplateData{
|
||||
App: a,
|
||||
Page: &detail,
|
||||
Widget: &view,
|
||||
IsNew: in.isNew,
|
||||
WidgetYAML: in.yamlText,
|
||||
LoadError: in.loadError,
|
||||
ErrorMessage: in.errorMessage,
|
||||
FieldReference: widgetFieldReferences[in.widgetType],
|
||||
WidgetExample: widgetStarters[in.widgetType],
|
||||
DocsURL: widgetDocsURL(in.widgetType),
|
||||
}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminWidgetTemplate, data)
|
||||
}
|
||||
|
||||
// widgetTypeFromYAML extracts just the `type:` field from a YAML mapping.
|
||||
// Used when re-rendering the editor after a save error so docs/reference
|
||||
// match what the user is actually editing.
|
||||
func widgetTypeFromYAML(yamlText string) string {
|
||||
var probe struct {
|
||||
Type string `yaml:"type"`
|
||||
}
|
||||
_ = yaml.Unmarshal([]byte(yamlText), &probe)
|
||||
return probe.Type
|
||||
}
|
||||
|
||||
// freshPagesFromDisk reads the on-disk config and returns the parsed pages
|
||||
// with slugs auto-generated. Admin views use this instead of a.Config.Pages
|
||||
// because the fsnotify watcher debounces 500ms after a save, and during that
|
||||
// window a.Config still reflects the pre-save state. Falls back to a copy
|
||||
// of the cached config on any error.
|
||||
func (a *application) freshPagesFromDisk() []page {
|
||||
contents, _, err := parseYAMLIncludes(a.ConfigPath)
|
||||
if err != nil {
|
||||
return a.Config.Pages
|
||||
}
|
||||
cfg, err := newConfigFromYAML(contents)
|
||||
if err != nil {
|
||||
return a.Config.Pages
|
||||
}
|
||||
for p := range cfg.Pages {
|
||||
if cfg.Pages[p].Slug == "" {
|
||||
cfg.Pages[p].Slug = titleToSlug(cfg.Pages[p].Title)
|
||||
}
|
||||
}
|
||||
return cfg.Pages
|
||||
}
|
||||
|
||||
func findPageBySlug(pages []page, slug string) (*page, int, bool) {
|
||||
for i := range pages {
|
||||
if pages[i].Slug == slug {
|
||||
return &pages[i], i, true
|
||||
}
|
||||
}
|
||||
return nil, -1, false
|
||||
}
|
||||
|
||||
// freshPageIndexBySlug looks up the on-disk page index by slug. Mutating
|
||||
// handlers must use this rather than a.pageIndexBySlug because the cached
|
||||
// a.Config can lag the file by up to ~500ms after a save.
|
||||
func (a *application) freshPageIndexBySlug(slug string) (int, bool) {
|
||||
_, idx, ok := findPageBySlug(a.freshPagesFromDisk(), slug)
|
||||
return idx, ok
|
||||
}
|
||||
|
||||
// loadWidgetYAML reads the on-disk config and returns the marshaled YAML for
|
||||
// one widget. Errors are returned as a string so the template can show them
|
||||
// without preventing the page from rendering.
|
||||
func loadWidgetYAML(path string, pageIdx, col, idx int) (string, string) {
|
||||
editor, err := loadConfigEditor(path)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
seq, pos, err := editor.widgetSlot(pageIdx, col, idx)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
out, err := yaml.Marshal(seq.Content[pos])
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
return string(out), ""
|
||||
}
|
||||
|
||||
// adminAccessAllowed enforces that admin endpoints either require auth, or
|
||||
// have been explicitly opted into without auth via config.admin.allow-without-auth.
|
||||
// Returns false if the response has been written and the caller should bail.
|
||||
func (a *application) adminAccessAllowed(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !a.RequiresAuth && !a.Config.Admin.AllowWithoutAuth {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`<!doctype html><meta charset=utf-8><title>Editing disabled</title>` +
|
||||
`<body style="font-family:sans-serif;padding:2rem;max-width:50rem;line-height:1.5">` +
|
||||
`<h1>Editing is disabled</h1>` +
|
||||
`<p>The edit UI requires authentication. Either:</p>` +
|
||||
`<ul>` +
|
||||
`<li>Configure <code>auth.users</code> in your config (recommended), or</li>` +
|
||||
`<li>Set <code>admin.allow-without-auth: true</code> in your config if you accept that anyone reachable on the network can edit it. (The legacy <code>admin</code> key still works for this option.)</li>` +
|
||||
`</ul>` +
|
||||
`</body>`))
|
||||
return false
|
||||
}
|
||||
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAdminTemplate(w http.ResponseWriter, t *template.Template, data adminTemplateData) {
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
Reference in New Issue
Block a user