Ctrl K
ComunidadGuías…

internal/server/settings.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package server

import (
	"bytes"
	"context"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"armdash/internal/auth"
	"armdash/internal/config"
	"armdash/internal/links"
	"armdash/internal/promql"
	"armdash/internal/system"
)

// ---- settings -------------------------------------------------------------
//
// Generated from the schemas, like the page it replaces: the core keys, each
// system's ConfigSchema and the links. Each box is its own form, saved on its
// own. A save changes the store, builds a new tree from it and only then
// writes the file, so a value that breaks the build never reaches the disk.
//
// A value from an env file or the environment wins over the page and renders
// disabled, naming the file. Secrets are never sent back to the browser.

type settingsField struct {
	Name, Label, Help, EnvName string
	Input                      string // text, url or password
	Value, Placeholder         string
	Locked                     string
	Disabled                   bool
	Err                        string
}

type settingsToggle struct {
	Name, Label, Locked string
	On, Disabled        bool
}

type settingsSection struct {
	ID, Title, Action string
	Toggle            *settingsToggle
	Fields            []settingsField
	Editable          bool
	Saved             bool
	Err               string
}

type settingsLinkRow struct {
	Index                         int
	ID, Title, URL, Mode, Framing string
	New                           bool
}

type settingsLinks struct {
	Rows      []settingsLinkRow
	Modes     []string
	Editable  bool
	Locked    string
	Saved     bool
	Err       string
	LastIndex int
}

type settingsLogin struct {
	User, Locked string
	Off          bool
	Editable     bool
	Saved        bool
	Err          string
}

type infoRow struct {
	Label, EnvName, Value, Source, Help string
	Set                                 bool
}

type settingsData struct {
	Writable  bool
	StorePath string
	Theme     string
	Welcome   bool
	Login     settingsLogin
	Core      settingsSection
	Systems   []settingsSection
	Links     settingsLinks
	Server    []infoRow
}

// saveState carries a failed form back into the page, so the owner keeps what
// they typed and sees which field was wrong.
type saveState struct {
	Section string
	Saved   string
	Err     string
	Posted  url.Values
	Fields  map[string]string
}

// fieldSpec is one editable key, from the core list or a system's schema.
type fieldSpec struct {
	Key, Label, Help string
	Kind             system.FieldKind
	Default          string
	Secret           bool
}

var coreFields = []fieldSpec{
	{Key: prometheusURLKey, Label: "Prometheus URL", Kind: system.KindURL,
		Help: "Every chart on every page is read from here, usually http://127.0.0.1:9090."},
}

func systemFields(sys system.System) []fieldSpec {
	var out []fieldSpec
	for _, f := range sys.ConfigSchema() {
		out = append(out, fieldSpec{Key: "system." + sys.ID() + "." + f.Key, Label: f.Label, Help: f.Help,
			Kind: f.Kind, Default: f.Default, Secret: f.Secret})
	}
	return out
}

func (t *tree) input(f fieldSpec, st saveState, section string) settingsField {
	in := settingsField{
		Name:     f.Key,
		Label:    f.Label,
		Help:     f.Help,
		EnvName:  config.EnvName(f.Key),
		Input:    "text",
		Locked:   t.cfg.Locked(f.Key),
		Disabled: !t.cfg.Writable(),
	}
	if in.Locked != "" {
		in.Disabled = true
	}
	switch f.Kind {
	case system.KindURL:
		in.Input = "url"
	case system.KindPassword:
		in.Input = "password"
	case system.KindBool:
		in.Input = "checkbox"
	}
	cur := t.cfg.Get(f.Key)
	switch {
	case f.Secret && cur != "":
		in.Placeholder = "set, leave empty to keep it"
	case f.Secret:
		in.Placeholder = "not set"
	default:
		in.Value = cur
		in.Placeholder = f.Default
	}
	if f.Kind == system.KindBool {
		in.Value = map[bool]string{true: "1", false: ""}[t.cfg.Bool(f.Key)]
	}
	if st.Section == section && st.Posted != nil && !f.Secret && in.Locked == "" {
		in.Value = st.Posted.Get(f.Key)
	}
	if st.Section == section {
		in.Err = st.Fields[f.Key]
	}
	return in
}

func (t *tree) section(id, title, action string, specs []fieldSpec, st saveState) settingsSection {
	sec := settingsSection{ID: id, Title: title, Action: action, Saved: st.Saved == id}
	if st.Section == id {
		sec.Err = st.Err
	}
	for _, f := range specs {
		in := t.input(f, st, id)
		if !in.Disabled {
			sec.Editable = true
		}
		sec.Fields = append(sec.Fields, in)
	}
	return sec
}

func (t *tree) settingsData(st saveState) settingsData {
	d := settingsData{
		Writable:  t.cfg.Writable(),
		StorePath: t.cfg.StorePath(),
		Welcome:   st.Saved == "setup",
	}

	user, _ := t.loginPair()
	d.Login = settingsLogin{User: user, Off: t.loginOff, Saved: st.Saved == "login"}
	if l := t.cfg.Locked(authHashKey); l != "" {
		d.Login.Locked = l
	} else if l := t.cfg.Locked(authUserKey); l != "" {
		d.Login.Locked = l
	}
	d.Login.Editable = d.Writable && d.Login.Locked == "" && t.hasLogin()
	if st.Section == "login" {
		d.Login.Err = st.Err
	}

	d.Core = t.section("core", "Core", "/settings/core", coreFields, st)

	for _, sys := range t.systems {
		id := "system-" + sys.ID()
		sec := t.section(id, sys.Title(), "/settings/system/"+sys.ID(), systemFields(sys), st)
		key := "system." + sys.ID() + ".enabled"
		tg := &settingsToggle{Name: key, Label: "Shown in the navbar", On: t.cfg.Enabled(sys.ID()),
			Locked: t.cfg.Locked(key)}
		tg.Disabled = tg.Locked != "" || !d.Writable
		if !tg.Disabled {
			sec.Editable = true
		}
		sec.Toggle = tg
		d.Systems = append(d.Systems, sec)
	}

	d.Links = settingsLinks{
		Modes:  []string{string(links.ModeFrame), string(links.ModeProxy), string(links.ModeTab)},
		Locked: t.linksLocked(),
		Saved:  st.Saved == "links",
	}
	d.Links.Editable = d.Writable && d.Links.Locked == ""
	if st.Section == "links" {
		d.Links.Err = st.Err
	}
	for i, l := range t.links {
		d.Links.Rows = append(d.Links.Rows, settingsLinkRow{Index: i, ID: l.ID, Title: l.Title,
			URL: l.URL.String(), Mode: string(l.Mode), Framing: l.Framing})
	}
	if st.Section == "links" && st.Posted != nil {
		d.Links.Rows = postedLinkRows(st.Posted)
	}
	if d.Links.Editable && (len(d.Links.Rows) == 0 || !d.Links.Rows[len(d.Links.Rows)-1].New) {
		d.Links.Rows = append(d.Links.Rows, settingsLinkRow{Index: len(d.Links.Rows), New: true,
			Mode: string(links.ModeFrame)})
	}
	d.Links.LastIndex = len(d.Links.Rows) - 1

	d.Server = []infoRow{
		t.info("core.addr", "Listen address", "127.0.0.1:9494", "Where plain HTTP is served. The -addr flag overrides it."),
		t.info("core.tls_addr", "HTTPS address", "127.0.0.1:9495", "Used when both the certificate and the key are set."),
		t.info(tlsCertKey, "TLS certificate", "HTTPS is off", ""),
		t.info(tlsKeyKey, "TLS key", "HTTPS is off", ""),
		t.info(dataDirKey, "Data directory", t.dataDirUnset(), "Settings saved here, uploaded floor plans and device positions."),
		t.info(trustedProxiesKey, "Trusted proxies", "Off: forwarding headers are ignored",
			"Addresses whose X-Forwarded-For and X-Forwarded-Proto are believed, for a reverse proxy in front."),
		t.info(authModeKey, "Login", "login",
			"none turns the login off, for a proxy in front that already lets only the owner through."),
		{Label: "Env files", Value: strings.Join(t.files, ", "), Set: len(t.files) > 0,
			Help: "Read at startup, later ones win, the environment wins over all. A value set there locks its field here."},
	}
	return d
}

func (t *tree) dataDirUnset() string {
	if dir := DataDir(t.cfg); dir != "" {
		return dir + "  (systemd)"
	}
	return "none, settings cannot be saved"
}

func (t *tree) info(key, label, unset, help string) infoRow {
	v := t.cfg.Get(key)
	r := infoRow{Label: label, EnvName: config.EnvName(key), Value: v, Source: t.cfg.Source(key), Help: help, Set: v != ""}
	if !r.Set {
		r.Value = unset
	}
	return r
}

// linksLocked names the file that sets the link list or any link, which makes
// the whole list read-only here: half a list from a file and half from the
// page would be impossible to reason about.
func (t *tree) linksLocked() string {
	if l := t.cfg.Locked(links.ListKey); l != "" {
		return l
	}
	prefix := config.EnvName("link") + "_"
	for _, k := range t.cfg.Keys() {
		if strings.HasPrefix(k, prefix) {
			if l := t.cfg.LockedVar(k); l != "" {
				return l
			}
		}
	}
	return ""
}

func (t *tree) handleSettings(w http.ResponseWriter, r *http.Request) {
	if t.setupMode() {
		t.renderSetup(w, r, http.StatusOK, "", "")
		return
	}
	if t.requireLogin(w, r) {
		return
	}
	t.renderSettings(w, r, http.StatusOK, saveState{Saved: r.URL.Query().Get("saved")})
}

func (t *tree) renderSettings(w http.ResponseWriter, r *http.Request, code int, st saveState) {
	sd := t.settingsData(st)
	sd.Theme = theme(r)
	t.page(w, r, code, "settings", sd, "Settings")
}

func (t *tree) renderSetup(w http.ResponseWriter, r *http.Request, code int, user, errMsg string) {
	t.page(w, r, code, "setup", map[string]string{"User": user, "Err": errMsg}, "Set up")
}

func (t *tree) page(w http.ResponseWriter, r *http.Request, code int, name string, data any, title string) {
	var body bytes.Buffer
	if err := t.tmpl.ExecuteTemplate(&body, name, data); err != nil {
		t.log.Error("settings render failed", "err", err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	d := t.layout(r, nil, "")
	d.PageTitle = title
	d.SettingsActive = true
	d.Body = template.HTML(body.String())
	t.renderCode(w, code, d)
}

// save applies changes to the store, builds a tree from them and writes the
// file. A failure at either step puts the old values back.
func (s *Server) save(changes map[string]string) error {
	s.saveMu.Lock()
	defer s.saveMu.Unlock()
	return s.saveLocked(changes)
}

func (s *Server) saveLocked(changes map[string]string) error {
	if len(changes) == 0 {
		return nil
	}
	undo, err := s.cfg.Set(changes)
	if err != nil {
		return err
	}
	if err := s.rebuild(); err != nil {
		_, _ = s.cfg.Set(undo)
		return err
	}
	if err := s.cfg.Save(); err != nil {
		_, _ = s.cfg.Set(undo)
		_ = s.rebuild()
		return fmt.Errorf("saving the settings: %w", err)
	}
	s.log.Info("settings saved", "keys", len(changes))
	return nil
}

// saveScoped is Deps.Save: a system's own keys, under its prefix only.
func (s *Server) saveScoped(prefix string) func(map[string]string) error {
	return func(changes map[string]string) error {
		full := make(map[string]string, len(changes))
		for k, v := range changes {
			full[prefix+k] = v
		}
		return s.save(full)
	}
}

// editor refuses a settings form from anyone but the logged-in owner.
func (t *tree) editor(w http.ResponseWriter, r *http.Request) bool {
	switch {
	case !t.loginOff && !t.hasLogin():
		t.refuse(w, r, http.StatusForbidden, "Editing is off", "No login exists yet.")
		return false
	case !system.CanEdit(r):
		t.refuse(w, r, http.StatusUnauthorized, "Not logged in", "Log in to make changes.")
		return false
	}
	return true
}

func saved(w http.ResponseWriter, r *http.Request, section string) {
	http.Redirect(w, r, "/settings?saved="+section+"#"+section, http.StatusSeeOther)
}

// collect turns a posted form into store changes. An unchanged value is left
// alone, so saving a form does not copy the committed defaults into the store;
// an emptied one is removed. An empty secret keeps the stored one.
func (t *tree) collect(specs []fieldSpec, form url.Values) (map[string]string, map[string]string) {
	changes, errs := map[string]string{}, map[string]string{}
	for _, f := range specs {
		if t.cfg.Locked(f.Key) != "" {
			continue
		}
		raw := strings.TrimSpace(form.Get(f.Key))
		cur := t.cfg.Get(f.Key)
		// An unticked checkbox is absent from the form, so unticked is "",
		// which removes the stored value and falls back to off.
		if f.Kind == system.KindBool {
			if t.cfg.Bool(f.Key) != (raw == "1") {
				changes[f.Key] = raw
			}
			continue
		}
		if raw == cur || f.Secret && raw == "" {
			continue
		}
		if raw != "" {
			if err := validate(f.Kind, raw); err != "" {
				errs[f.Key] = err
				continue
			}
		}
		changes[f.Key] = raw
	}
	return changes, errs
}

func validate(kind system.FieldKind, v string) string {
	switch kind {
	case system.KindURL:
		u, err := url.Parse(v)
		if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
			return "Needs an http:// or https:// address."
		}
		if u.User != nil {
			return "Leave the credentials out of the address."
		}
	case system.KindDuration:
		if d, err := time.ParseDuration(v); err != nil || d <= 0 {
			return "Needs a duration such as 60s or 5m."
		}
	}
	return ""
}

func (t *tree) failed(w http.ResponseWriter, r *http.Request, section string, err string, fields map[string]string) {
	t.renderSettings(w, r, http.StatusUnprocessableEntity,
		saveState{Section: section, Err: err, Posted: r.PostForm, Fields: fields})
}

func (t *tree) handleSaveCore(w http.ResponseWriter, r *http.Request) {
	if !t.editor(w, r) {
		return
	}
	_ = r.ParseForm()
	changes, errs := t.collect(coreFields, r.PostForm)
	if len(errs) > 0 {
		t.failed(w, r, "core", "Nothing was saved, see the field below.", errs)
		return
	}
	if err := t.save(changes); err != nil {
		t.failed(w, r, "core", err.Error(), nil)
		return
	}
	saved(w, r, "core")
}

func (t *tree) handleSaveSystem(w http.ResponseWriter, r *http.Request) {
	if !t.editor(w, r) {
		return
	}
	var sys system.System
	for _, s := range t.systems {
		if s.ID() == r.PathValue("id") {
			sys = s
		}
	}
	if sys == nil {
		http.NotFound(w, r)
		return
	}
	_ = r.ParseForm()
	section := "system-" + sys.ID()
	specs := systemFields(sys)
	changes, errs := t.collect(specs, r.PostForm)
	if len(errs) > 0 {
		t.failed(w, r, section, "Nothing was saved, see the fields below.", errs)
		return
	}

	// A stored secret belongs to the address it was entered for. Pointing
	// the address elsewhere drops it, unless a new one comes with the change,
	// so a session in the wrong hands cannot send the password to a host of
	// its choosing.
	for _, f := range specs {
		if _, moved := changes[f.Key]; !moved || f.Kind != system.KindURL {
			continue
		}
		for _, sec := range specs {
			_, replaced := changes[sec.Key]
			if sec.Secret && !replaced && t.cfg.Source(sec.Key) == config.SourceSettings {
				changes[sec.Key] = ""
			}
		}
	}

	key := "system." + sys.ID() + ".enabled"
	if t.cfg.Locked(key) == "" {
		on := r.PostForm.Get(key) == "1"
		if on != t.cfg.Enabled(sys.ID()) {
			changes[key] = map[bool]string{true: "1", false: "0"}[on]
		}
	}
	if err := t.save(changes); err != nil {
		t.failed(w, r, section, err.Error(), nil)
		return
	}
	saved(w, r, section)
}

func postedLinkRows(form url.Values) []settingsLinkRow {
	n, _ := strconv.Atoi(form.Get("rows"))
	var rows []settingsLinkRow
	for i := 0; i < n && i < 100; i++ {
		get := func(f string) string { return strings.TrimSpace(form.Get(f + "." + strconv.Itoa(i))) }
		rows = append(rows, settingsLinkRow{Index: i, ID: strings.ToLower(get("id")), Title: get("title"),
			URL: get("url"), Mode: get("mode"), Framing: get("framing"), New: get("new") == "1"})
	}
	return rows
}

// linkID makes an id from a title for a new link: its letters and digits.
func linkID(title string) string {
	var b strings.Builder
	for _, c := range strings.ToLower(title) {
		if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' {
			b.WriteRune(c)
		}
	}
	return b.String()
}

func (t *tree) handleSaveLinks(w http.ResponseWriter, r *http.Request) {
	if !t.editor(w, r) {
		return
	}
	if l := t.linksLocked(); l != "" {
		t.refuse(w, r, http.StatusConflict, "Links are set in "+l, "Edit them there.")
		return
	}
	_ = r.ParseForm()
	rows := postedLinkRows(r.PostForm)

	// The arrows and the remove button are submit buttons: one press moves or
	// drops one row and saves.
	if i, dir, ok := strings.Cut(r.PostForm.Get("move"), ":"); ok {
		if n, err := strconv.Atoi(i); err == nil && n >= 0 && n < len(rows) {
			m := n - 1
			if dir == "down" {
				m = n + 1
			}
			if m >= 0 && m < len(rows) && !rows[m].New && !rows[n].New {
				rows[n], rows[m] = rows[m], rows[n]
			}
		}
	}

	var ids []string
	seen := map[string]bool{}
	changes := map[string]string{}
	for _, row := range rows {
		if r.PostForm.Get("remove") == strconv.Itoa(row.Index) {
			continue
		}
		if row.New && row.ID == "" && row.Title == "" && row.URL == "" {
			continue
		}
		id := row.ID
		if id == "" {
			id = linkID(row.Title)
		}
		name := row.Title
		if name == "" {
			name = id
		}
		switch {
		case !links.ValidID(id):
			t.failed(w, r, "links", fmt.Sprintf("%q needs an id of letters a-z and digits only.", name), nil)
			return
		case seen[id]:
			t.failed(w, r, "links", fmt.Sprintf("Two links share the id %q.", id), nil)
			return
		case row.URL == "":
			t.failed(w, r, "links", fmt.Sprintf("%q needs a URL.", name), nil)
			return
		}
		seen[id] = true
		ids = append(ids, id)
		title, mode := row.Title, row.Mode
		if title == id {
			title = ""
		}
		if mode == string(links.ModeFrame) {
			mode = ""
		}
		changes[links.Key(id, "url")] = row.URL
		changes[links.Key(id, "title")] = title
		changes[links.Key(id, "mode")] = mode
		changes[links.Key(id, "framing")] = row.Framing
	}
	for _, l := range t.links {
		if !seen[l.ID] {
			for _, f := range links.Fields() {
				changes[links.Key(l.ID, f)] = ""
			}
		}
	}
	changes[links.ListKey] = strings.Join(ids, ",")
	if err := t.save(changes); err != nil {
		t.failed(w, r, "links", err.Error(), nil)
		return
	}
	saved(w, r, "links")
}

// handleSetup creates the first login, on a fresh install only, and logs the
// new owner straight in.
func (t *tree) handleSetup(w http.ResponseWriter, r *http.Request) {
	t.saveMu.Lock()
	defer t.saveMu.Unlock()
	if !t.setupMode() {
		t.refuse(w, r, http.StatusForbidden, "Already set up", "A login exists, log in with it.")
		return
	}
	user := strings.TrimSpace(r.PostFormValue("user"))
	pass, confirm := r.PostFormValue("password"), r.PostFormValue("confirm")
	msg := ""
	switch {
	case user == "":
		msg = "Choose a user name."
	case pass != confirm:
		msg = "The two passwords differ."
	}
	var hash string
	if msg == "" {
		var err error
		if hash, err = auth.Hash(pass); err != nil {
			msg = "The password needs at least " + strconv.Itoa(auth.MinPasswordLen) + " characters."
		}
	}
	if msg == "" {
		if err := t.saveLocked(map[string]string{authUserKey: user, authHashKey: hash}); err != nil {
			msg = err.Error()
		}
	}
	if msg != "" {
		t.renderSetup(w, r, http.StatusUnprocessableEntity, user, msg)
		return
	}
	id, err := t.sessions.Create()
	if err != nil {
		http.Error(w, "could not create a session", http.StatusInternalServerError)
		return
	}
	t.setSession(w, r, id, int(auth.SessionTTL.Seconds()))
	t.log.Info("login created", "user", user, "addr", t.clientAddr(r))
	http.Redirect(w, r, "/settings?saved=setup", http.StatusSeeOther)
}

// handleChangeLogin changes the stored user name or password. Either needs the
// current password, and a new password ends every other session.
func (t *tree) handleChangeLogin(w http.ResponseWriter, r *http.Request) {
	if !t.editor(w, r) {
		return
	}
	if t.cfg.Locked(authHashKey) != "" || t.cfg.Locked(authUserKey) != "" {
		t.refuse(w, r, http.StatusConflict, "The login is set in an env file", "Change it there with armdash passwd.")
		return
	}
	_ = r.ParseForm()
	addr := t.clientAddr(r)
	if ok, _ := t.limiter.Allowed(addr); !ok {
		t.failed(w, r, "login", "Too many wrong passwords. Try again later.", nil)
		return
	}
	curUser, hash := t.loginPair()
	if !auth.Verify(r.PostForm.Get("current"), hash) {
		t.limiter.Fail(addr)
		t.failed(w, r, "login", "The current password is wrong.", nil)
		return
	}
	t.limiter.Reset(addr)
	changes := map[string]string{}
	if u := strings.TrimSpace(r.PostForm.Get("user")); u != "" && u != curUser {
		changes[authUserKey] = u
	}
	pass := r.PostForm.Get("password")
	if pass != "" {
		if pass != r.PostForm.Get("confirm") {
			t.failed(w, r, "login", "The two new passwords differ.", nil)
			return
		}
		h, err := auth.Hash(pass)
		if err != nil {
			t.failed(w, r, "login", "The new password needs at least "+strconv.Itoa(auth.MinPasswordLen)+" characters.", nil)
			return
		}
		changes[authHashKey] = h
	}
	if err := t.save(changes); err != nil {
		t.failed(w, r, "login", err.Error(), nil)
		return
	}
	if pass != "" {
		if c, err := r.Cookie(sessionCookie); err == nil {
			t.sessions.DeleteOthers(c.Value)
		}
	}
	saved(w, r, "login")
}

// ---- status ---------------------------------------------------------------

type statusGroup struct {
	Title  string
	Checks []system.Check
}

// handleStatus is the box beside the settings: what works and what is still
// to do. Loaded after the page, because it asks Prometheus and the FRITZ!Box,
// and one slow answer should not hold the form back.
func (t *tree) handleStatus(w http.ResponseWriter, r *http.Request) {
	if t.setupMode() || t.hasLogin() && !system.CanEdit(r) {
		http.Error(w, "log in first", http.StatusUnauthorized)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()

	groups := []statusGroup{{Title: "Core", Checks: t.coreStatus(ctx)}}
	var wg sync.WaitGroup
	var mu sync.Mutex
	perSystem := map[string][]system.Check{}
	for _, sys := range t.enabled() {
		c, ok := sys.(system.Checker)
		if !ok {
			continue
		}
		wg.Add(1)
		go func() {
			defer wg.Done()
			checks := c.Status(ctx)
			mu.Lock()
			perSystem[sys.ID()] = checks
			mu.Unlock()
		}()
	}
	wg.Wait()
	for _, sys := range t.enabled() {
		if checks := perSystem[sys.ID()]; len(checks) > 0 {
			groups = append(groups, statusGroup{Title: sys.Title(), Checks: checks})
		}
	}

	var buf bytes.Buffer
	if err := t.tmpl.ExecuteTemplate(&buf, "settings-status", groups); err != nil {
		t.log.Error("status render failed", "err", err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = buf.WriteTo(w)
}

func (t *tree) coreStatus(ctx context.Context) []system.Check {
	var out []system.Check
	user, _ := t.loginPair()
	switch {
	case t.loginOff:
		out = append(out, system.Check{Title: "Login", Level: "ok",
			Detail: "Off, set by " + config.EnvName(authModeKey) + ". Anyone who reaches this page can change settings."})
	case user == "":
		out = append(out, system.Check{Title: "Login", Level: "warning",
			Detail: "None, and no data directory to save one in. Anyone can read this page."})
	case t.cfg.Locked(authUserKey) != "":
		out = append(out, system.Check{Title: "Login", Level: "ok", Detail: user + ", set in " + t.cfg.Locked(authUserKey)})
	default:
		out = append(out, system.Check{Title: "Login", Level: "ok", Detail: user})
	}
	if p := t.cfg.StorePath(); p != "" {
		out = append(out, system.Check{Title: "Settings", Level: "ok", Detail: "Saved in " + p})
	} else {
		out = append(out, system.Check{Title: "Settings", Level: "warning",
			Detail: "No data directory, so nothing here can be saved. Set " + config.EnvName(dataDirKey) + "."})
	}

	if t.PromURL() == "" {
		return append(out, system.Check{Title: "Prometheus", Level: "danger",
			Detail: "Not set. Enter its URL under Core, every chart reads from it."})
	}
	prom := promql.New(t.PromURL)
	n, ok, err := prom.QueryOne(ctx, "count(up == 1)")
	if err != nil {
		return append(out, system.Check{Title: "Prometheus", Level: "danger", Detail: "Unreachable: " + err.Error()})
	}
	if !ok {
		n = 0
	}
	out = append(out, system.Check{Title: "Prometheus", Level: "ok", Detail: fmt.Sprintf("Reachable, %.0f targets up", n)})
	if _, ok, err := prom.QueryOne(ctx, "count(armdash_collector_up)"); err == nil && !ok {
		out = append(out, system.Check{Title: "Scraping armdash", Level: "warning",
			Detail: "Prometheus has no samples from armdash's /metrics yet. Add its scrape job, see docs/install.md."})
	}
	return out
}