mirror of
https://github.com/octoleo/syncthing.git
synced 2025-02-08 14:58:26 +00:00
cmd/syncthing: Refactor out staticsServer (prev. embeddedStatic) a bit
The purpose of this operation is to separate the serving of GUI assets a bit from the serving of the REST API. It's by no means complete. The end goal is something like a combined server type that embeds a statics server and an API server and wraps it in authentication and HTTPS and stuff, plus possibly a named pipe server that only provides the API and does not wrap in the same authentication etc. GitHub-Pull-Request: https://github.com/syncthing/syncthing/pull/3273
This commit is contained in:
parent
b7e186b370
commit
03a8027efc
@ -7,13 +7,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"mime"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -26,7 +23,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rcrowley/go-metrics"
|
"github.com/rcrowley/go-metrics"
|
||||||
"github.com/syncthing/syncthing/lib/auto"
|
|
||||||
"github.com/syncthing/syncthing/lib/config"
|
"github.com/syncthing/syncthing/lib/config"
|
||||||
"github.com/syncthing/syncthing/lib/db"
|
"github.com/syncthing/syncthing/lib/db"
|
||||||
"github.com/syncthing/syncthing/lib/discover"
|
"github.com/syncthing/syncthing/lib/discover"
|
||||||
@ -54,8 +50,7 @@ type apiService struct {
|
|||||||
cfg configIntf
|
cfg configIntf
|
||||||
httpsCertFile string
|
httpsCertFile string
|
||||||
httpsKeyFile string
|
httpsKeyFile string
|
||||||
assetDir string
|
statics *staticsServer
|
||||||
themes []string
|
|
||||||
model modelIntf
|
model modelIntf
|
||||||
eventSub events.BufferedSubscription
|
eventSub events.BufferedSubscription
|
||||||
discoverer discover.CachingMux
|
discoverer discover.CachingMux
|
||||||
@ -123,7 +118,7 @@ func newAPIService(id protocol.DeviceID, cfg configIntf, httpsCertFile, httpsKey
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
httpsCertFile: httpsCertFile,
|
httpsCertFile: httpsCertFile,
|
||||||
httpsKeyFile: httpsKeyFile,
|
httpsKeyFile: httpsKeyFile,
|
||||||
assetDir: assetDir,
|
statics: newStaticsServer(cfg.GUI().Theme, assetDir),
|
||||||
model: m,
|
model: m,
|
||||||
eventSub: eventSub,
|
eventSub: eventSub,
|
||||||
discoverer: discoverer,
|
discoverer: discoverer,
|
||||||
@ -135,25 +130,6 @@ func newAPIService(id protocol.DeviceID, cfg configIntf, httpsCertFile, httpsKey
|
|||||||
systemLog: systemLog,
|
systemLog: systemLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
// Load themes from compiled in assets.
|
|
||||||
for file := range auto.Assets() {
|
|
||||||
theme := strings.Split(file, "/")[0]
|
|
||||||
if _, ok := seen[theme]; !ok {
|
|
||||||
seen[theme] = struct{}{}
|
|
||||||
service.themes = append(service.themes, theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if assetDir != "" {
|
|
||||||
// Load any extra themes from the asset override dir.
|
|
||||||
for _, dir := range dirNames(assetDir) {
|
|
||||||
if _, ok := seen[dir]; !ok {
|
|
||||||
seen[dir] = struct{}{}
|
|
||||||
service.themes = append(service.themes, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,20 +281,11 @@ func (s *apiService) Serve() {
|
|||||||
mux.HandleFunc("/qr/", s.getQR)
|
mux.HandleFunc("/qr/", s.getQR)
|
||||||
|
|
||||||
// Serve compiled in assets unless an asset directory was set (for development)
|
// Serve compiled in assets unless an asset directory was set (for development)
|
||||||
assets := &embeddedStatic{
|
mux.Handle("/", s.statics)
|
||||||
theme: s.cfg.GUI().Theme,
|
|
||||||
lastModified: time.Now().Truncate(time.Second), // must truncate, for the wire precision is 1s
|
|
||||||
mut: sync.NewRWMutex(),
|
|
||||||
assetDir: s.assetDir,
|
|
||||||
assets: auto.Assets(),
|
|
||||||
}
|
|
||||||
mux.Handle("/", assets)
|
|
||||||
|
|
||||||
// Handle the special meta.js path
|
// Handle the special meta.js path
|
||||||
mux.HandleFunc("/meta.js", s.getJSMetadata)
|
mux.HandleFunc("/meta.js", s.getJSMetadata)
|
||||||
|
|
||||||
s.cfg.Subscribe(assets)
|
|
||||||
|
|
||||||
guiCfg := s.cfg.GUI()
|
guiCfg := s.cfg.GUI()
|
||||||
|
|
||||||
// Wrap everything in CSRF protection. The /rest prefix should be
|
// Wrap everything in CSRF protection. The /rest prefix should be
|
||||||
@ -401,6 +368,10 @@ func (s *apiService) CommitConfiguration(from, to config.Configuration) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if to.GUI.Theme != from.GUI.Theme {
|
||||||
|
s.statics.setTheme(to.GUI.Theme)
|
||||||
|
}
|
||||||
|
|
||||||
// Tell the serve loop to restart
|
// Tell the serve loop to restart
|
||||||
s.configChanged <- struct{}{}
|
s.configChanged <- struct{}{}
|
||||||
|
|
||||||
@ -842,7 +813,6 @@ func (s *apiService) getSystemStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
res["pathSeparator"] = string(filepath.Separator)
|
res["pathSeparator"] = string(filepath.Separator)
|
||||||
res["uptime"] = int(time.Since(startTime).Seconds())
|
res["uptime"] = int(time.Since(startTime).Seconds())
|
||||||
res["startTime"] = startTime
|
res["startTime"] = startTime
|
||||||
res["themes"] = s.themes
|
|
||||||
|
|
||||||
sendJSON(w, res)
|
sendJSON(w, res)
|
||||||
}
|
}
|
||||||
@ -1192,136 +1162,6 @@ func (s *apiService) getSystemBrowse(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, ret)
|
sendJSON(w, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
type embeddedStatic struct {
|
|
||||||
theme string
|
|
||||||
lastModified time.Time
|
|
||||||
mut sync.RWMutex
|
|
||||||
assetDir string
|
|
||||||
assets map[string][]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s embeddedStatic) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
file := r.URL.Path
|
|
||||||
|
|
||||||
if file[0] == '/' {
|
|
||||||
file = file[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(file) == 0 {
|
|
||||||
file = "index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mut.RLock()
|
|
||||||
theme := s.theme
|
|
||||||
modified := s.lastModified
|
|
||||||
s.mut.RUnlock()
|
|
||||||
|
|
||||||
// Check for an override for the current theme.
|
|
||||||
if s.assetDir != "" {
|
|
||||||
p := filepath.Join(s.assetDir, s.theme, filepath.FromSlash(file))
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
http.ServeFile(w, r, p)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for a compiled in asset for the current theme.
|
|
||||||
bs, ok := s.assets[theme+"/"+file]
|
|
||||||
if !ok {
|
|
||||||
// Check for an overridden default asset.
|
|
||||||
if s.assetDir != "" {
|
|
||||||
p := filepath.Join(s.assetDir, config.DefaultTheme, filepath.FromSlash(file))
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
http.ServeFile(w, r, p)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for a compiled in default asset.
|
|
||||||
bs, ok = s.assets[config.DefaultTheme+"/"+file]
|
|
||||||
if !ok {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modifiedSince, err := http.ParseTime(r.Header.Get("If-Modified-Since"))
|
|
||||||
if err == nil && !modified.After(modifiedSince) {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mtype := s.mimeTypeForFile(file)
|
|
||||||
if len(mtype) != 0 {
|
|
||||||
w.Header().Set("Content-Type", mtype)
|
|
||||||
}
|
|
||||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
||||||
w.Header().Set("Content-Encoding", "gzip")
|
|
||||||
} else {
|
|
||||||
// ungzip if browser not send gzip accepted header
|
|
||||||
var gr *gzip.Reader
|
|
||||||
gr, _ = gzip.NewReader(bytes.NewReader(bs))
|
|
||||||
bs, _ = ioutil.ReadAll(gr)
|
|
||||||
gr.Close()
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bs)))
|
|
||||||
w.Header().Set("Last-Modified", modified.UTC().Format(http.TimeFormat))
|
|
||||||
// Strictly, no-cache means the same as this. However FF and IE treat no-cache as
|
|
||||||
// "don't hold a local cache at all", whereas everyone seems to treat this as
|
|
||||||
// you can hold a local cache, but you must revalidate it before using it.
|
|
||||||
w.Header().Set("Cache-Control", "max-age=0, must-revalidate")
|
|
||||||
|
|
||||||
w.Write(bs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s embeddedStatic) mimeTypeForFile(file string) string {
|
|
||||||
// We use a built in table of the common types since the system
|
|
||||||
// TypeByExtension might be unreliable. But if we don't know, we delegate
|
|
||||||
// to the system.
|
|
||||||
ext := filepath.Ext(file)
|
|
||||||
switch ext {
|
|
||||||
case ".htm", ".html":
|
|
||||||
return "text/html"
|
|
||||||
case ".css":
|
|
||||||
return "text/css"
|
|
||||||
case ".js":
|
|
||||||
return "application/javascript"
|
|
||||||
case ".json":
|
|
||||||
return "application/json"
|
|
||||||
case ".png":
|
|
||||||
return "image/png"
|
|
||||||
case ".ttf":
|
|
||||||
return "application/x-font-ttf"
|
|
||||||
case ".woff":
|
|
||||||
return "application/x-font-woff"
|
|
||||||
case ".svg":
|
|
||||||
return "image/svg+xml"
|
|
||||||
default:
|
|
||||||
return mime.TypeByExtension(ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyConfiguration implements the config.Committer interface
|
|
||||||
func (s *embeddedStatic) VerifyConfiguration(from, to config.Configuration) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommitConfiguration implements the config.Committer interface
|
|
||||||
func (s *embeddedStatic) CommitConfiguration(from, to config.Configuration) bool {
|
|
||||||
s.mut.Lock()
|
|
||||||
if s.theme != to.GUI.Theme {
|
|
||||||
s.theme = to.GUI.Theme
|
|
||||||
s.lastModified = time.Now()
|
|
||||||
}
|
|
||||||
s.mut.Unlock()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *embeddedStatic) String() string {
|
|
||||||
return fmt.Sprintf("embeddedStatic@%p", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *apiService) toNeedSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo {
|
func (s *apiService) toNeedSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo {
|
||||||
res := make([]jsonDBFileInfo, len(fs))
|
res := make([]jsonDBFileInfo, len(fs))
|
||||||
for i, f := range fs {
|
for i, f := range fs {
|
||||||
|
176
cmd/syncthing/gui_statics.go
Normal file
176
cmd/syncthing/gui_statics.go
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// Copyright (C) 2014 The Syncthing Authors.
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/syncthing/syncthing/lib/auto"
|
||||||
|
"github.com/syncthing/syncthing/lib/config"
|
||||||
|
"github.com/syncthing/syncthing/lib/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staticsServer struct {
|
||||||
|
assetDir string
|
||||||
|
assets map[string][]byte
|
||||||
|
availableThemes []string
|
||||||
|
|
||||||
|
mut sync.RWMutex
|
||||||
|
theme string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStaticsServer(theme, assetDir string) *staticsServer {
|
||||||
|
s := &staticsServer{
|
||||||
|
assetDir: assetDir,
|
||||||
|
assets: auto.Assets(),
|
||||||
|
mut: sync.NewRWMutex(),
|
||||||
|
theme: theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
// Load themes from compiled in assets.
|
||||||
|
for file := range auto.Assets() {
|
||||||
|
theme := strings.Split(file, "/")[0]
|
||||||
|
if _, ok := seen[theme]; !ok {
|
||||||
|
seen[theme] = struct{}{}
|
||||||
|
s.availableThemes = append(s.availableThemes, theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if assetDir != "" {
|
||||||
|
// Load any extra themes from the asset override dir.
|
||||||
|
for _, dir := range dirNames(assetDir) {
|
||||||
|
if _, ok := seen[dir]; !ok {
|
||||||
|
seen[dir] = struct{}{}
|
||||||
|
s.availableThemes = append(s.availableThemes, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticsServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/themes.json":
|
||||||
|
s.serveThemes(w, r)
|
||||||
|
default:
|
||||||
|
s.serveAsset(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticsServer) serveAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
file := r.URL.Path
|
||||||
|
|
||||||
|
if file[0] == '/' {
|
||||||
|
file = file[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(file) == 0 {
|
||||||
|
file = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mut.RLock()
|
||||||
|
theme := s.theme
|
||||||
|
s.mut.RUnlock()
|
||||||
|
|
||||||
|
// Check for an override for the current theme.
|
||||||
|
if s.assetDir != "" {
|
||||||
|
p := filepath.Join(s.assetDir, theme, filepath.FromSlash(file))
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
http.ServeFile(w, r, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a compiled in asset for the current theme.
|
||||||
|
bs, ok := s.assets[theme+"/"+file]
|
||||||
|
if !ok {
|
||||||
|
// Check for an overridden default asset.
|
||||||
|
if s.assetDir != "" {
|
||||||
|
p := filepath.Join(s.assetDir, config.DefaultTheme, filepath.FromSlash(file))
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
http.ServeFile(w, r, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a compiled in default asset.
|
||||||
|
bs, ok = s.assets[config.DefaultTheme+"/"+file]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mtype := s.mimeTypeForFile(file)
|
||||||
|
if len(mtype) != 0 {
|
||||||
|
w.Header().Set("Content-Type", mtype)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
} else {
|
||||||
|
// ungzip if browser not send gzip accepted header
|
||||||
|
var gr *gzip.Reader
|
||||||
|
gr, _ = gzip.NewReader(bytes.NewReader(bs))
|
||||||
|
bs, _ = ioutil.ReadAll(gr)
|
||||||
|
gr.Close()
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bs)))
|
||||||
|
|
||||||
|
w.Write(bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticsServer) serveThemes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sendJSON(w, map[string][]string{
|
||||||
|
"themes": s.availableThemes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticsServer) mimeTypeForFile(file string) string {
|
||||||
|
// We use a built in table of the common types since the system
|
||||||
|
// TypeByExtension might be unreliable. But if we don't know, we delegate
|
||||||
|
// to the system.
|
||||||
|
ext := filepath.Ext(file)
|
||||||
|
switch ext {
|
||||||
|
case ".htm", ".html":
|
||||||
|
return "text/html"
|
||||||
|
case ".css":
|
||||||
|
return "text/css"
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript"
|
||||||
|
case ".json":
|
||||||
|
return "application/json"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".ttf":
|
||||||
|
return "application/x-font-ttf"
|
||||||
|
case ".woff":
|
||||||
|
return "application/x-font-woff"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
default:
|
||||||
|
return mime.TypeByExtension(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticsServer) setTheme(theme string) {
|
||||||
|
s.mut.Lock()
|
||||||
|
s.theme = theme
|
||||||
|
s.mut.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticsServer) String() string {
|
||||||
|
return fmt.Sprintf("staticsServer@%p", s)
|
||||||
|
}
|
@ -116,7 +116,7 @@ func TestAssetsDir(t *testing.T) {
|
|||||||
gw.Close()
|
gw.Close()
|
||||||
foo := buf.Bytes()
|
foo := buf.Bytes()
|
||||||
|
|
||||||
e := embeddedStatic{
|
e := &staticsServer{
|
||||||
theme: "foo",
|
theme: "foo",
|
||||||
mut: sync.NewRWMutex(),
|
mut: sync.NewRWMutex(),
|
||||||
assetDir: "testdata",
|
assetDir: "testdata",
|
||||||
|
@ -49,6 +49,7 @@ angular.module('syncthing.core')
|
|||||||
$scope.failedCurrentFolder = undefined;
|
$scope.failedCurrentFolder = undefined;
|
||||||
$scope.failedPageSize = 10;
|
$scope.failedPageSize = 10;
|
||||||
$scope.scanProgress = {};
|
$scope.scanProgress = {};
|
||||||
|
$scope.themes = [];
|
||||||
|
|
||||||
$scope.localStateTotal = {
|
$scope.localStateTotal = {
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
@ -88,6 +89,7 @@ angular.module('syncthing.core')
|
|||||||
refreshConnectionStats();
|
refreshConnectionStats();
|
||||||
refreshDeviceStats();
|
refreshDeviceStats();
|
||||||
refreshFolderStats();
|
refreshFolderStats();
|
||||||
|
refreshThemes();
|
||||||
|
|
||||||
$http.get(urlbase + '/system/version').success(function (data) {
|
$http.get(urlbase + '/system/version').success(function (data) {
|
||||||
if ($scope.version.version && $scope.version.version !== data.version) {
|
if ($scope.version.version && $scope.version.version !== data.version) {
|
||||||
@ -599,6 +601,12 @@ angular.module('syncthing.core')
|
|||||||
}).error($scope.emitHTTPError);
|
}).error($scope.emitHTTPError);
|
||||||
}, 2500);
|
}, 2500);
|
||||||
|
|
||||||
|
var refreshThemes = debounce(function () {
|
||||||
|
$http.get("themes.json").success(function (data) { // no urlbase here as this is served by the asset handler
|
||||||
|
$scope.themes = data.themes;
|
||||||
|
}).error($scope.emitHTTPError);
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
$scope.refresh = function () {
|
$scope.refresh = function () {
|
||||||
refreshSystem();
|
refreshSystem();
|
||||||
refreshConnectionStats();
|
refreshConnectionStats();
|
||||||
@ -627,7 +635,7 @@ angular.module('syncthing.core')
|
|||||||
return 'outofsync';
|
return 'outofsync';
|
||||||
}
|
}
|
||||||
if (state === 'scanning') {
|
if (state === 'scanning') {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderCfg.devices.length <= 1) {
|
if (folderCfg.devices.length <= 1) {
|
||||||
|
@ -141,10 +141,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" ng-if="system.themes.length > 1">
|
<div class="form-group" ng-if="themes.length > 1">
|
||||||
<label>GUI Theme</label>
|
<label>GUI Theme</label>
|
||||||
<select class="form-control" ng-model="tmpGUI.theme">
|
<select class="form-control" ng-model="tmpGUI.theme">
|
||||||
<option ng-repeat="theme in system.themes.sort()" value="{{ theme }}">
|
<option ng-repeat="theme in themes.sort()" value="{{ theme }}">
|
||||||
{{ themeName(theme) }}
|
{{ themeName(theme) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user