f30a80841e
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login. A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab. The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now refuse a protected stack. In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp: new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated. Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind (first_visit default, email, none; none behind the family gate says the family sign-in is the login). "Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound into FileBrowser is text, never a link. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
346 lines
14 KiB
Go
346 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// THE APP PAGE (v0.315.0) — one page per app, four tabs, each tab its own URL.
|
|
//
|
|
// Until v0.314.0 one app was spread over six pages: the details page, the install/settings page, the
|
|
// logs page, the export page, the per-app backup panel, and a details card for moving data. Every
|
|
// „Vissza" on them went to the app LIST, so there was no way back to the app, and Start/Restart/Stop
|
|
// sat in a page header looking like menu buttons.
|
|
//
|
|
// /apps/{slug} the default tab — settings when installed, the guide when not
|
|
// /apps/{slug}/settings „Beallitasok" — Mukodes, the cards that need the household, the form
|
|
// /apps/{slug}/data „Adatok es mentes" — data folders, move, backup settings, export
|
|
// /apps/{slug}/guide „Utmutato" — description, screenshots, first steps, guide, FAQ
|
|
// /apps/{slug}/logs „Naplo" — the log, polled only here
|
|
// /apps/{slug}/install the install wizard (a page, not a tab)
|
|
//
|
|
// WHY SERVER-RENDERED TABS AND NOT PANELS. Links in alerts and e-mails have to land ON a tab, and the
|
|
// log poller must not run on the other three. One page with hidden panels gives neither.
|
|
//
|
|
// THE DEFAULT TAB IS RENDERED, NOT REDIRECTED. `/apps/{slug}` serves the settings tab's content with
|
|
// the URL unchanged, so the address an alert, a bookmark or the app list carries stays the app's one
|
|
// address.
|
|
//
|
|
// THE OLD URLS STILL WORK, AND ONLY FOR READS. Every `/stacks/{name}/…` GET answers 302 to its tab —
|
|
// never 301, which browsers cache beyond anyone's reach. Every POST on those paths is untouched: the
|
|
// backup form, the app-email toggle and the reveal endpoints keep their URLs and their handlers.
|
|
|
|
const (
|
|
appTabSettings = "settings"
|
|
appTabData = "data"
|
|
appTabGuide = "guide"
|
|
appTabLogs = "logs"
|
|
appTabInstall = "install"
|
|
)
|
|
|
|
// appTabOrder is the tab bar, left to right. The guide is last of the three „about" tabs and the log
|
|
// after it, because the household opens the log least often and the order must not move under them.
|
|
var appTabOrder = []string{appTabSettings, appTabData, appTabGuide, appTabLogs}
|
|
|
|
// appPageHandler serves `/apps/{slug}` and `/apps/{slug}/{tab}`.
|
|
//
|
|
// A NOT-INSTALLED app has one view and one URL: every tab suffix answers 302 to the base, because a
|
|
// settings tab for an app with no settings and a log tab for a container that does not exist are
|
|
// four ways to reach the same empty page.
|
|
func (s *Server) appPageHandler(w http.ResponseWriter, r *http.Request, slug, tab string) {
|
|
found, data, ok := s.appCommonData(w, r, slug)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// v0.320.0: a protected infra stack is installed by definition — it is part of the box (appCommonData sets
|
|
// Infra). It gets the settings, guide and log tabs; never the data tab (its export, move and backup are not the
|
|
// household's), and never the install wizard. Pinned by TestAppPage_InfraAppHasNoInstall.
|
|
infra, _ := data["Infra"].(bool)
|
|
installed := found.Deployed || infra
|
|
if !installed {
|
|
if tab != "" {
|
|
http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound)
|
|
return
|
|
}
|
|
tab = appTabGuide
|
|
}
|
|
if infra && tab == appTabData {
|
|
http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound)
|
|
return
|
|
}
|
|
if tab == "" {
|
|
tab = appTabSettings
|
|
}
|
|
|
|
data["ActiveTab"] = tab
|
|
tabs := s.appTabLinks(r, slug, installed, tab)
|
|
if infra {
|
|
kept := tabs[:0]
|
|
for _, t := range tabs {
|
|
if t.Key != appTabData {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
tabs = kept
|
|
}
|
|
data["AppTabs"] = tabs
|
|
data["Deployed"] = installed
|
|
// The shell's „Megnyitas" shows only for an app that is actually up — a link to a stopped app's
|
|
// address lands on the catch-all page, which is a worse answer than no link.
|
|
data["AppOperational"] = installed && isOperationalState(found.State)
|
|
|
|
switch tab {
|
|
case appTabSettings:
|
|
s.renderAppSettingsTab(w, r, found, data)
|
|
case appTabData:
|
|
s.renderAppDataTab(w, r, found, data)
|
|
case appTabGuide:
|
|
s.renderAppGuideTab(w, r, found, data)
|
|
case appTabLogs:
|
|
s.renderAppLogsTab(w, r, found, data)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// AppTabLink is one entry of the tab bar.
|
|
type AppTabLink struct {
|
|
Key string // the ASCII tab id, also the `data-tab` attribute a test asserts on
|
|
Href string
|
|
Label string // filled per request, in the household's language
|
|
Active bool
|
|
}
|
|
|
|
// appTabLinks builds the tab bar. Empty for a not-installed app: it has one view, so a bar of one
|
|
// tab would be furniture.
|
|
//
|
|
// The LABEL is taken here and not in the template, with a literal key per tab, because a template
|
|
// that composed its key (`T (printf "app_page.tab_%s" .Key)`) would be invisible to the
|
|
// marker-coverage gate — the key would exist in no greppable form and a missing translation would
|
|
// render the key itself to the household.
|
|
func (s *Server) appTabLinks(r *http.Request, slug string, deployed bool, active string) []AppTabLink {
|
|
return s.appTabLinksLang(s.langFor(r), slug, deployed, active)
|
|
}
|
|
|
|
// appTabLinksLang is appTabLinks for a language that is already known — the i18n parity harness
|
|
// renders one case in both languages from one request, so it cannot go through langFor.
|
|
func (s *Server) appTabLinksLang(lang, slug string, deployed bool, active string) []AppTabLink {
|
|
if !deployed {
|
|
return nil
|
|
}
|
|
labels := map[string]string{
|
|
appTabSettings: s.msgLang(lang, "app_page.tab_settings"),
|
|
appTabData: s.msgLang(lang, "app_page.tab_data"),
|
|
appTabGuide: s.msgLang(lang, "app_page.tab_guide"),
|
|
appTabLogs: s.msgLang(lang, "app_page.tab_logs"),
|
|
}
|
|
base := s.cfg.AppPageURL(slug)
|
|
out := make([]AppTabLink, 0, len(appTabOrder))
|
|
for _, key := range appTabOrder {
|
|
out = append(out, AppTabLink{Key: key, Href: base + "/" + key, Label: labels[key], Active: key == active})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderAppSettingsTab — „Mukodes" first (the three buttons that used to sit in the page header),
|
|
// then the cards that still want something from the household, then the install-time and optional
|
|
// settings the install page used to show in its deployed mode.
|
|
func (s *Server) renderAppSettingsTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
|
|
// The form half of this tab is deployPageData's — the same keys the install page gets, with
|
|
// AlreadyDeployed true. Merged OVER the common data, except the four keys that say which page
|
|
// this is: those belong to the tab, not to the form.
|
|
//
|
|
// v0.320.0: an infra stack has no install form, no app.yaml and no catalog fields — its tab is „Mukodes"
|
|
// (restart only) and, for FileBrowser, the login. deployPageData is not asked.
|
|
if infra, _ := data["Infra"].(bool); !infra {
|
|
if form, ok := s.deployPageData(w, r, found.Name); ok {
|
|
mergeAppPageData(data, form)
|
|
} else {
|
|
return
|
|
}
|
|
data["AfterStartFields"] = s.afterStartFields(found)
|
|
}
|
|
data["LoginAnswer"] = loginAnswerFor(data, &found.Meta)
|
|
data["Title"] = found.Meta.DisplayName
|
|
data["TitleKey"], data["TitleArgs"] = "page.title.app_settings", []interface{}{found.Meta.DisplayName}
|
|
s.executeTemplate(w, r, "app_settings", data)
|
|
}
|
|
|
|
// renderAppDataTab — where the files are, how to move them, how they are backed up, how to take them
|
|
// out. The three used to be a card on the details page, a panel reached from the backup page, and a
|
|
// page of its own.
|
|
func (s *Server) renderAppDataTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
|
|
data["StackName"] = found.Name
|
|
data["DisplayName"] = found.Meta.DisplayName
|
|
if s.backupMgr != nil {
|
|
info := s.backupMgr.Tier2Info(found.Name)
|
|
data["Tier2"] = info
|
|
if !info.IsHDDApp {
|
|
// R-499: say where THIS box's whole-system backup goes, not where one usually does.
|
|
data["SystemBackup"] = systemBackupFact(s.resolveBackupTargetState(r.Context()))
|
|
}
|
|
}
|
|
if s.appExporter != nil {
|
|
data["ExportOffered"] = true
|
|
data["Drives"] = s.storageDriveList()
|
|
}
|
|
if flash := s.flashFrom(r, "flash"); flash != "" {
|
|
data["Flash"] = flash
|
|
}
|
|
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
|
|
data["FlashError"] = flashErr
|
|
}
|
|
data["Title"] = found.Meta.DisplayName
|
|
data["TitleKey"], data["TitleArgs"] = "page.title.app_data", []interface{}{found.Meta.DisplayName}
|
|
s.executeTemplate(w, r, "app_data", data)
|
|
}
|
|
|
|
// renderAppGuideTab — the app's own copy: what it is for, what it looks like, the first steps, the
|
|
// guide, the FAQ and the docs link. For a not-installed app this is the whole page, with „Telepites".
|
|
func (s *Server) renderAppGuideTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
|
|
// A translation longer than the Hungarian guide is dropped silently by the overlay — say so once
|
|
// here, where there is a logger and a request language.
|
|
if secs, faq := found.Meta.GuideOverlayExtras(s.langFor(r)); secs > 0 || faq > 0 {
|
|
s.logger.Printf("[WARN] [web] guide overlay for app %q in %q carries %d section(s) and %d FAQ entr(y/ies) beyond the Hungarian count — the extras are not rendered", found.Meta.Slug, s.langFor(r), secs, faq)
|
|
}
|
|
data["Title"] = found.Meta.DisplayName
|
|
data["TitleKey"], data["TitleArgs"] = "page.title.app_guide", []interface{}{found.Meta.DisplayName}
|
|
s.executeTemplate(w, r, "app_guide", data)
|
|
}
|
|
|
|
// renderAppLogsTab — today's logs page inside the shell. `?raw=1` still answers plain text, because
|
|
// that is what the tab's own poller asks for (and what the old /stacks/{name}/logs?raw=1 asked for,
|
|
// whose redirect carries the query through).
|
|
func (s *Server) renderAppLogsTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
|
|
logs, err := s.stackMgr.GetLogs(found.Name, 200)
|
|
if err != nil {
|
|
logs = s.msg(r, "logs.fetch_failed", err)
|
|
}
|
|
if r.URL.Query().Get("raw") == "1" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte(logs))
|
|
return
|
|
}
|
|
data["Logs"] = logs
|
|
data["Title"] = found.Meta.DisplayName
|
|
data["TitleKey"], data["TitleArgs"] = "page.title.logs", []interface{}{found.Meta.DisplayName}
|
|
s.executeTemplate(w, r, "app_logs", data)
|
|
}
|
|
|
|
// appInstallPageHandler is `/apps/{slug}/install` — the install wizard, reached from the guide tab's
|
|
// „Telepites" button. It renders what the deploy page always rendered for a not-installed app; the
|
|
// difference is where its Vissza goes, which is now the app, not the list.
|
|
func (s *Server) appInstallPageHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
name := s.stackNameForSlug(slug)
|
|
if name == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// v0.320.0: part of the box, never installed — the address answers with the app's page, like a tab of a
|
|
// not-installed app does. The deploy POST refuses it too (api deployStack, stacks.DeployStack).
|
|
if s.cfg.IsProtectedStack(name) {
|
|
http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound)
|
|
return
|
|
}
|
|
s.deployHandler(w, r, name)
|
|
}
|
|
|
|
// appPageDataKeysOwnedByTab are the keys that say WHICH page this is. deployPageData sets them for
|
|
// the install page; on a tab they would overwrite the tab's own title and nav highlight, so the merge
|
|
// keeps the tab's.
|
|
var appPageDataKeysOwnedByTab = map[string]bool{
|
|
"Page": true, "Title": true, "TitleKey": true, "TitleArgs": true,
|
|
"ActiveTab": true, "AppTabs": true, "Deployed": true, "AppOperational": true,
|
|
}
|
|
|
|
// mergeAppPageData copies src into dst, leaving the tab-owned keys alone. A key src does not set is
|
|
// never written, so the common data's value survives — which is what keeps EffectiveSubdomain (set
|
|
// always by appCommonData, only sometimes by deployPageData) correct on the settings tab.
|
|
func mergeAppPageData(dst, src map[string]interface{}) {
|
|
for k, v := range src {
|
|
if appPageDataKeysOwnedByTab[k] {
|
|
continue
|
|
}
|
|
dst[k] = v
|
|
}
|
|
}
|
|
|
|
// ── the old URLs ──────────────────────────────────────────────────────────────────────────────────
|
|
|
|
// redirectStackGETToAppTab answers an old `/stacks/{name}/{suffix}` GET with a 302 to the matching
|
|
// tab. The query string is carried through — `?raw=1` on the logs URL is the poller of a page that
|
|
// was open when the box updated, and `?flash=…` comes back from a save.
|
|
//
|
|
// 302, never 301: a 301 is cached by the browser for as long as it likes, and a wrong one cannot be
|
|
// taken back. These paths will be redirects for one release, not forever.
|
|
func (s *Server) redirectStackGETToAppTab(w http.ResponseWriter, r *http.Request, name, tab string) {
|
|
slug := s.slugForStackName(name)
|
|
if slug == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
dest := s.cfg.AppPageURL(slug)
|
|
if tab != "" {
|
|
dest += "/" + tab
|
|
}
|
|
if q := r.URL.RawQuery; q != "" {
|
|
dest += "?" + q
|
|
}
|
|
http.Redirect(w, r, dest, http.StatusFound)
|
|
}
|
|
|
|
// redirectStackDeployGET sends the old settings/install URL to whichever of the two it now is: an
|
|
// installed app has a settings tab, a not-installed one has the install wizard.
|
|
func (s *Server) redirectStackDeployGET(w http.ResponseWriter, r *http.Request, name string) {
|
|
st := s.stackBySlug(s.slugForStackName(name))
|
|
if st == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
tab := appTabInstall
|
|
if st.Deployed {
|
|
tab = appTabSettings
|
|
}
|
|
s.redirectStackGETToAppTab(w, r, name, tab)
|
|
}
|
|
|
|
// appTabPathSuffix splits `{slug}` or `{slug}/{tab}` out of the path after `/apps/`, and reports
|
|
// whether the suffix is one this router knows. An unknown suffix is NOT a tab and must not be read as
|
|
// part of a slug — `/apps/grocy/nonsense` is a 404, not the app page for „grocy/nonsense".
|
|
func appTabPathSuffix(rest string) (slug, tab string, known bool) {
|
|
rest = strings.Trim(rest, "/")
|
|
if rest == "" {
|
|
return "", "", false
|
|
}
|
|
slug, tail, found := strings.Cut(rest, "/")
|
|
if !found {
|
|
return slug, "", true
|
|
}
|
|
switch tail {
|
|
case appTabSettings, appTabData, appTabGuide, appTabLogs, appTabInstall:
|
|
return slug, tail, true
|
|
}
|
|
return slug, tail, false
|
|
}
|
|
|
|
// appPageURLFor is the household-facing URL of one tab, for a handler that redirects after a save.
|
|
func (s *Server) appPageURLFor(slug, tab string) string {
|
|
dest := s.cfg.AppPageURL(slug)
|
|
if tab != "" {
|
|
dest += "/" + tab
|
|
}
|
|
return dest
|
|
}
|
|
|
|
// appPageURLForName is appPageURLFor keyed by stack name, for the two save handlers that know the
|
|
// name and not the slug. It falls back to the app list rather than inventing a URL.
|
|
func (s *Server) appPageURLForName(name, tab string) string {
|
|
if slug := s.slugForStackName(name); slug != "" {
|
|
return s.appPageURLFor(slug, tab)
|
|
}
|
|
return "/stacks"
|
|
}
|