Phase 3: embed web UI with go:embed, pinned TypeScript toolchain, drop i18n, add /healthz
continuous-integration/drone/push Build encountered an error

- html/embed.go embeds html/ (pages, css, img, js, video); src/assets.go
  serves it, with os.DirFS("html") under -dev. Static assets get an ETag
  and Cache-Control: no-cache; HTML pages are still templated (only the
  login error message is substituted now).
- Delete the generated src/webUI.go (783 KB base64), src/html-build.go and
  cmd/webui-gen; Dockerfile no longer runs a generator.
- Language layer removed: 254 {{.x}} placeholders inlined as English
  strings in ts/*.ts and the two auth pages; html/lang/en.json, the
  LanguageUI struct and the 'language' setting are gone.
- ts/tsconfig.json + package.json pin typescript 5.9.3; the seven sources
  compile (ES2020, global scripts) into one committed html/js/app.js.
  Ten unreferenced legacy scripts under html/js/ deleted; all pages load
  js/app.js.
- Fix the six type errors that blocked a clean compile, including a real
  bug: a missing semicolon in the search shortcut handler made the code
  call the result of preventDefault(), so the shortcut threw instead of
  focusing the search box.
- /healthz liveness endpoint; Dockerfile healthcheck and README use it.
- Drone: go vet, and a webui-check step that rebuilds the bundle and fails
  if the committed app.js is stale.
- README-DEV.md documents build, UI toolchain, -dev, versioning, CI.
This commit is contained in:
2026-09-26 12:46:45 +10:00
parent 51c7830067
commit ddc70e170a
47 changed files with 2208 additions and 7373 deletions
+52
View File
@@ -0,0 +1,52 @@
package src
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"os"
"path"
"strings"
"sync"
webui "xteve/html"
)
// webFS serves the web interface. It is the embedded copy by default and the
// local html/ directory when xTeVe runs with -dev.
var webFS fs.FS = webui.FS
var webETags sync.Map // path -> ETag, only used when not in dev mode
// initWebFS : selects embedded or on-disk web files.
func initWebFS(dev bool) {
if dev {
webFS = os.DirFS("html")
return
}
webFS = webui.FS
}
// readWebFile : reads a file from the web interface, name relative to html/.
func readWebFile(name string) ([]byte, error) {
name = strings.TrimPrefix(path.Clean("/"+name), "/")
if name == "" || name == "." {
return nil, fs.ErrNotExist
}
return fs.ReadFile(webFS, name)
}
// webFileETag : content hash used for HTTP caching of static web assets.
func webFileETag(name string, content []byte) string {
if !System.Dev {
if v, ok := webETags.Load(name); ok {
return v.(string)
}
}
sum := sha256.Sum256(content)
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
if !System.Dev {
webETags.Store(name, etag)
}
return etag
}
+1 -4
View File
@@ -153,10 +153,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
showInfo(fmt.Sprintf("Streaming Status:Playlist: %s - No new connections available. Tuner = %d", playlist.PlaylistName, playlist.Tuner))
if value, ok := webUI["html/video/stream-limit.ts"]; ok {
var content string
content = GetHTMLString(value.(string))
if content, err := readWebFile("video/stream-limit.ts"); err == nil {
w.WriteHeader(200)
w.Header().Set("Content-type", "video/mpeg")
+2 -13
View File
@@ -188,16 +188,8 @@ func Init() (err error) {
System.URLBase = fmt.Sprintf("%s://%s:%s", System.ServerProtocol.WEB, System.IPAddress, Settings.Port)
// HTML Dateien erstellen, mit dev == true werden die lokalen HTML Dateien verwendet
if System.Dev == true {
HTMLInit("webUI", "src", "html"+string(os.PathSeparator), "src"+string(os.PathSeparator)+"webUI.go")
err = BuildGoFile()
if err != nil {
return
}
}
// Mit dev == true werden die lokalen HTML Dateien verwendet, sonst die eingebetteten
initWebFS(System.Dev)
// DLNA Server starten
err = SSDP()
@@ -205,9 +197,6 @@ func Init() (err error) {
return
}
// HTML Datein laden
loadHTMLMap()
return
}
-148
View File
@@ -1,148 +0,0 @@
package src
import (
"bufio"
"encoding/base64"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"runtime"
"strings"
)
var htmlFolder string
var goFile string
var mapName string
var packageName string
var blankMap = make(map[string]any)
// HTMLInit : Dateipfade festlegen
// mapName = Name der zu erstellenden map
// htmlFolder: Ordner der HTML Dateien
// packageName: Name des package
func HTMLInit(name, pkg, folder, file string) {
htmlFolder = folder
goFile = file
mapName = name
packageName = pkg
}
// BuildGoFile : Erstellt das GO Dokument
func BuildGoFile() error {
var err = checkHTMLFile(htmlFolder)
if err != nil {
return err
}
var content string
content += `package ` + packageName + "\n\n"
content += `var ` + mapName + ` = make(map[string]interface{})` + "\n\n"
content += "func loadHTMLMap() {" + "\n\n"
content += createMapFromFiles(htmlFolder) + "\n"
content += "}" + "\n\n"
writeStringToFile(goFile, content)
return nil
}
// GetHTMLString : base64 -> string
func GetHTMLString(base string) string {
content, _ := base64.StdEncoding.DecodeString(base)
return string(content)
}
func createMapFromFiles(folder string) string {
var path = getLocalPath(folder)
err := filepath.Walk(path, readFilesToMap)
if err != nil {
checkErr(err)
}
var content strings.Builder
for key := range blankMap {
var newKey = key
content.WriteString(` ` + mapName + `["` + newKey + `"` + `] = "` + blankMap[key].(string) + `"` + "\n")
}
return content.String()
}
func readFilesToMap(path string, info os.FileInfo, err error) error {
if info.IsDir() == false {
var base64Str = fileToBase64(getLocalPath(path))
blankMap[path] = base64Str
}
return nil
}
func fileToBase64(file string) string {
imgFile, _ := os.Open(file)
defer imgFile.Close()
// create a new buffer base on file size
fInfo, _ := imgFile.Stat()
var size = fInfo.Size()
buf := make([]byte, int64(size))
// read file content into buffer
fReader := bufio.NewReader(imgFile)
fReader.Read(buf)
imgBase64Str := base64.StdEncoding.EncodeToString(buf)
return imgBase64Str
}
func getLocalPath(filename string) string {
path, file := filepath.Split(filename)
var newPath = filepath.Dir(path)
var newFileName = newPath + "/" + file
return newFileName
}
func writeStringToFile(filename, content string) error {
err := ioutil.WriteFile(getPlatformFile(filename), []byte(content), 0644)
if err != nil {
checkErr(err)
return err
}
return nil
}
func checkHTMLFile(filename string) error {
if _, err := os.Stat(getLocalPath(filename)); os.IsNotExist(err) {
fmt.Println(filename)
checkErr(err)
return err
}
return nil
}
func checkErr(err error) {
if err != nil {
_, file, line, _ := runtime.Caller(1)
log.Println("ERROR: [", err, "] in ", file, line)
}
}
-7
View File
@@ -267,7 +267,6 @@ type SettingsStruct struct {
FilesUpdate bool `json:"files.update"`
Filter map[int64]any `json:"filter"`
Key string `json:"key,omitempty"`
Language string `json:"language"`
LogEntriesRAM int `json:"log.entries.ram"`
M3U8AdaptiveBandwidthMBPS int `json:"m3u8.adaptive.bandwidth.mbps"`
MappingFirstChannel float64 `json:"mapping.first.channel"`
@@ -285,9 +284,3 @@ type SettingsStruct struct {
WizardCompleted bool `json:"wizard.completed"`
}
// LanguageUI : Sprache für das WebUI
type LanguageUI struct {
Login struct {
Failed string
}
}
-1
View File
@@ -138,7 +138,6 @@ func loadSettings() (settings SettingsStruct, err error) {
defaults["files.update"] = true
defaults["filter"] = make(map[string]any)
defaults["git.branch"] = System.Branch
defaults["language"] = "en"
defaults["log.entries.ram"] = 500
defaults["mapping.first.channel"] = 1000
defaults["xepg.missing.epg.mode"] = "strict"
-50
View File
File diff suppressed because one or more lines are too long
+56 -75
View File
@@ -38,6 +38,7 @@ func StartWebserver() (err error) {
http.HandleFunc("/api/", API)
http.HandleFunc("/images/", Images)
http.HandleFunc("/data_images/", DataImages)
http.HandleFunc("/healthz", Healthz)
//http.HandleFunc("/auto/", Auto)
@@ -595,44 +596,36 @@ func WS(w http.ResponseWriter, r *http.Request) {
return
}
// Healthz : Liveness endpoint for container healthchecks and monitoring.
// Unauthenticated on purpose; it exposes only the version and whether a scan is running.
func Healthz(w http.ResponseWriter, r *http.Request) {
var status = map[string]any{
"status": "ok",
"version": System.Version + "." + System.Build,
"scanInProgress": System.ScanInProgress == 1,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mapToJSON(status)))
}
// Web : Web Server /web/
func Web(w http.ResponseWriter, r *http.Request) {
var lang = make(map[string]any)
var err error
var requestFile = strings.TrimPrefix(r.URL.Path, "/web")
requestFile = strings.TrimPrefix(requestFile, "/")
var requestFile = strings.Replace(r.URL.Path, "/web", "html", -1)
var content, contentType, file string
var language LanguageUI
// Values substituted into the HTML pages (JS and CSS are served verbatim).
var tmpl = map[string]any{"authenticationErr": ""}
setGlobalDomain(r.Host)
if System.Dev == true {
if requestFile == "" {
lang, err = loadJSONFileToMap(fmt.Sprintf("html/lang/%s.json", Settings.Language))
if err != nil {
ShowError(err, 000)
}
} else {
var languageFile = "html/lang/en.json"
if value, ok := webUI[languageFile].(string); ok {
content = GetHTMLString(value)
lang = jsonToMap(content)
}
}
err = json.Unmarshal([]byte(mapToJSON(lang)), &language)
if err != nil {
ShowError(err, 000)
return
}
if getFilenameFromPath(requestFile) == "html" {
var file string
if System.ScanInProgress == 0 {
@@ -645,16 +638,16 @@ func Web(w http.ResponseWriter, r *http.Request) {
switch System.ConfigurationWizard {
case true:
file = requestFile + "configuration.html"
file = "configuration.html"
Settings.AuthenticationWEB = false
case false:
file = requestFile + "index.html"
file = "index.html"
}
if System.ScanInProgress == 1 {
file = requestFile + "maintenance.html"
file = "maintenance.html"
}
switch Settings.AuthenticationWEB {
@@ -692,8 +685,8 @@ func Web(w http.ResponseWriter, r *http.Request) {
var token, err = authentication.UserAuthentication(username, password)
if err != nil {
file = requestFile + "login.html"
lang["authenticationErr"] = language.Login.Failed
file = "login.html"
tmpl["authenticationErr"] = "User authentication failed"
break
}
@@ -708,17 +701,16 @@ func Web(w http.ResponseWriter, r *http.Request) {
return
case "GET":
lang["authenticationErr"] = ""
_, token, err := authentication.CheckTheValidityOfTheTokenFromHTTPHeader(w, r)
if err != nil {
file = requestFile + "login.html"
file = "login.html"
break
}
err = checkAuthorizationLevel(token, "authentication.web")
if err != nil {
file = requestFile + "login.html"
file = "login.html"
break
}
@@ -732,58 +724,47 @@ func Web(w http.ResponseWriter, r *http.Request) {
}
if len(allUserData) == 0 && Settings.AuthenticationWEB == true {
file = requestFile + "create-first-user.html"
file = "create-first-user.html"
}
}
requestFile = file
if value, ok := webUI[requestFile]; ok {
}
content = GetHTMLString(value.(string))
content, err := readWebFile(requestFile)
if err != nil {
httpStatusError(w, r, 404)
return
}
if contentType == "text/plain" {
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(requestFile))
}
var contentType = getContentType(requestFile)
} else {
switch contentType {
httpStatusError(w, r, 404)
case "text/html":
content = []byte(parseTemplate(string(content), tmpl))
w.Header().Set("Cache-Control", "no-store")
case "text/plain":
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(requestFile))
default:
// Static asset: let the browser revalidate cheaply with an ETag.
var etag = webFileETag(requestFile, content)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
if value, ok := webUI[requestFile].(string); ok {
content = GetHTMLString(value)
contentType = getContentType(requestFile)
if contentType == "text/plain" {
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(requestFile))
}
} else {
httpStatusError(w, r, 404)
return
}
contentType = getContentType(requestFile)
if System.Dev == true {
// Lokale Webserver Dateien werden geladen, nur für die Entwicklung
content, _ = readStringFromFile(requestFile)
}
w.Header().Add("Content-Type", contentType)
w.WriteHeader(200)
if contentType == "text/html" || contentType == "application/javascript" {
content = parseTemplate(content, lang)
}
w.Write([]byte(content))
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
w.Write(content)
}
// API : API request /api/