diff --git a/frontend/package.json b/frontend/package.json index 61280978..32e92bac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@fortawesome/vue-fontawesome": "^0.1.9", "apexcharts": "^3.15.0", "axios": "^0.19.1", + "codemirror-colorpicker": "^1.9.66", "core-js": "^3.4.4", "moment": "^2.24.0", "querystring": "^0.2.0", diff --git a/frontend/src/assets/scss/base.scss b/frontend/src/assets/scss/base.scss index c50cddff..d13ce2a2 100644 --- a/frontend/src/assets/scss/base.scss +++ b/frontend/src/assets/scss/base.scss @@ -91,6 +91,22 @@ HTML,BODY { color: #6d6d6d; } +.font-0 { + font-size: 5pt; +} + +.font-1 { + font-size: 7pt; +} + +.font-2 { + font-size: 9pt; +} + +.font-3 { + font-size: 11pt; +} + .badge { color: white; border-radius: $global-border-radius; diff --git a/frontend/src/components/API.js b/frontend/src/components/API.js index 3d4670ec..76cb6104 100644 --- a/frontend/src/components/API.js +++ b/frontend/src/components/API.js @@ -152,6 +152,22 @@ class Api { return axios.get('/api/logs/last').then(response => (response.data)) } + async theme () { + return axios.get('/api/theme').then(response => (response.data)) + } + + async theme_generate (create=true) { + if (create) { + return axios.get('/api/theme/create').then(response => (response.data)) + } else { + return axios.delete('/api/theme').then(response => (response.data)) + } + } + + async theme_save (data) { + return axios.post('/api/theme', data).then(response => (response.data)) + } + async login (username, password) { const f = {username: username, password: password} return axios.post('/api/login', qs.stringify(f)) diff --git a/frontend/src/components/Dashboard/ThemeEditor.vue b/frontend/src/components/Dashboard/ThemeEditor.vue index 2b27c484..2c1a47f4 100644 --- a/frontend/src/components/Dashboard/ThemeEditor.vue +++ b/frontend/src/components/Dashboard/ThemeEditor.vue @@ -1,24 +1,46 @@ <template > - <form method="POST" action="settings/css"> + <div> + <div v-if="loaded && !directory" class="jumbotron jumbotron-fluid"> + <div class="text-center col-12"> + <h1 class="display-5">Enable Local Assets</h1> + <span class="lead">Customize your status page design by enabling local assets. This will create a 'assets' directory containing all CSS.<p> + <button @click.prevent="createAssets" :disabled="pending" href="#" class="btn btn-primary mt-3">Enable Local Assets</button> + </p></span> + </div> + </div> + <form v-if="loaded && directory" @submit.prevent="saveAssets" :disabled="pending"> <ul class="nav nav-pills mb-3" id="pills-tab" role="tablist"> <li class="nav-item col text-center"> - <a class="nav-link active" id="pills-vars-tab" data-toggle="pill" href="#pills-vars" role="tab" aria-controls="pills-vars" aria-selected="true">Variables</a> + <a @click.prevent="changeTab('vars')" class="nav-link" :class="{active: tab === 'vars'}" id="pills-vars-tab" data-toggle="pill" href="#pills-vars" role="tab" aria-controls="pills-vars" aria-selected="true">Variables</a> </li> <li class="nav-item col text-center"> - <a class="nav-link" id="pills-theme-tab" data-toggle="pill" href="#pills-theme" role="tab" aria-controls="pills-theme" aria-selected="false">Base Theme</a> + <a @click.prevent="changeTab('base')" class="nav-link" :class="{active: tab === 'base'}" id="pills-base-tab" data-toggle="pill" href="#pills-base" role="tab" aria-controls="pills-base" aria-selected="false">Base Theme</a> </li> <li class="nav-item col text-center"> - <a class="nav-link" id="pills-mobile-tab" data-toggle="pill" href="#pills-mobile" role="tab" aria-controls="pills-mobile" aria-selected="false">Mobile</a> + <a @click.prevent="changeTab('mobile')" class="nav-link" :class="{active: tab === 'mobile'}" id="pills-mobile-tab" data-toggle="pill" href="#pills-mobile" role="tab" aria-controls="pills-mobile" aria-selected="false">Mobile</a> </li> </ul> <div class="tab-content" id="pills-tabContent"> - <div class="tab-pane show active" id="pills-vars" role="tabpanel" aria-labelledby="pills-vars-tab"> - <codemirror v-if="loaded" v-model="base" :options="cmOptions"></codemirror> + <div class="tab-pane show" :class="{active: tab === 'vars'}" id="pills-vars" role="tabpanel" aria-labelledby="pills-vars-tab"> + <codemirror v-if="loaded && tab === 'vars'" v-model="vars" :options="cmOptions" class="codemirrorInput"/> + </div> + <div class="tab-pane show" :class="{active: tab === 'base'}" id="pills-base" role="tabpanel" aria-labelledby="pills-base-tab"> + <codemirror v-if="loaded && tab === 'base'" v-model="base" :options="cmOptions" class="codemirrorInput"/> + </div> + <div class="tab-pane show" :class="{active: tab === 'mobile'}" id="pills-mobile" role="tabpanel" aria-labelledby="pills-mobile-tab"> + <codemirror v-if="loaded && tab === 'mobile'" v-model="mobile" :options="cmOptions" class="codemirrorInput"/> </div> </div> - <button type="submit" class="btn btn-primary btn-block mt-2">Save Style</button> - <a href="settings/delete_assets" class="btn btn-danger btn-block confirm-btn">Delete All Assets</a> + <div v-if="error" class="alert alert-danger mt-3" style="white-space: pre-line;">{{error}}</div> + + <button @submit.prevent="saveAssets" type="submit" class="btn btn-primary btn-block mt-2" :disabled="pending">{{pending ? "Saving..." : "Save Style"}}</button> + <button v-if="directory" @click.prevent="deleteAssets" href="#" class="btn btn-danger btn-block confirm-btn" :disabled="pending">Delete Local Assets</button> + + <h6 class="text-muted text-monospace text-sm-center font-1 mt-3"> + Asset Directory: {{directory}} + </h6> </form> + </div> </template> <script> @@ -26,46 +48,109 @@ // require component import { codemirror } from 'vue-codemirror' - // require styles + import 'codemirror/mode/css/css.js' + import 'codemirror/lib/codemirror.css' + import 'codemirror-colorpicker/dist/codemirror-colorpicker.css' + import 'codemirror-colorpicker' export default { - name: 'ThemeEditor', - components: { - codemirror - }, - props: { - core: { - type: Object, - required: true - } - }, - data() { - return { - base: "", - loaded: false, - cmOptions: { - // codemirror options - tabSize: 4, - mode: 'text/javascript', - theme: 'base16-dark', - lineNumbers: true, - line: true, - // more codemirror options, 更多 codemirror 的高级配置... - } - } - }, - async mounted() { - this.base = await Api.scss_base() - window.console.log(this.base) - this.loaded = true - }, - methods: { + name: 'ThemeEditor', + components: { + codemirror + }, + props: { + core: { + type: Object, + required: true + } + }, + data () { + return { + base: "", + vars: "", + mobile: "", + error: null, + directory: null, + tab: "vars", + loaded: false, + pending: false, + cmOptions: { + height: 600, + tabSize: 4, + lineNumbers: true, + matchBrackets: true, + mode: "text/x-scss", + line: true, + colorpicker: true + } + } + }, + computed: { + codemirror () { + } + }, + async mounted () { + await this.fetchTheme() + }, + methods: { + async fetchTheme() { + this.loaded = true + this.pending = true + const theme = await Api.theme() + this.directory = theme.directory + if (this.directory) { + this.base = theme.base + this.vars = theme.variables + this.mobile = theme.mobile + } + this.pending = false + this.loaded = true + }, + async createAssets() { + this.pending = true + const resp = await Api.theme_generate(true) + window.console.log(resp) + this.pending = false + await this.fetchTheme() + }, + async deleteAssets() { + this.pending = true + let c = confirm('Are you sure you want to delete all local assets?') + if (c) { + const resp = await Api.theme_generate(false) + window.console.log(resp) + await this.fetchTheme() + } + this.pending = false + }, + async saveAssets() { + this.pending = true + const data = {base: this.base, variables: this.vars, mobile: this.mobile} + const resp = await Api.theme_save(data) + if (resp.error) { + this.error = resp.error + this.pending = false + return + } else { + this.error = null + } + this.pending = false + window.console.log(resp) + await this.fetchTheme() + }, + changeTab (v) { + this.tab = v + } + } } -} </script> <!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> +<style> + .CodeMirror { + border: 1px solid #eee; + height: 550px; + } </style> diff --git a/frontend/src/components/Service/ServiceBlock.vue b/frontend/src/components/Service/ServiceBlock.vue index b179348c..d4c0a066 100644 --- a/frontend/src/components/Service/ServiceBlock.vue +++ b/frontend/src/components/Service/ServiceBlock.vue @@ -37,7 +37,7 @@ </span> </div> <div class="col-sm-12 col-md-2"> - <router-link :to="serviceLink(service)" class="btn btn-sm float-right dyn-dark btn-block" :class="{'bg-success': service.online, 'bg-danger': !service.online}"> + <router-link :to="serviceLink(service)" class="btn btn-sm float-right dyn-dark btn-block text-white" :class="{'bg-success': service.online, 'bg-danger': !service.online}"> View Service</router-link> </div> </div> diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index 0e94da46..b5566789 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -44,7 +44,7 @@ export default { }, async created() { const core = await Api.core() - context.commit("setCore", core); + this.$store.commit("setCore", core); if (!core.setup) { this.$router.push('/setup') } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d9525ee7..5c4889a3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2093,6 +2093,11 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= +codemirror-colorpicker@^1.9.66: + version "1.9.66" + resolved "https://registry.yarnpkg.com/codemirror-colorpicker/-/codemirror-colorpicker-1.9.66.tgz#de1f7373be73bc0f242c50a1b682858f5130b3af" + integrity sha512-wI4qLOzJ49Zs8jyVb/6eDdS02cd7Wi8NUb6QGSG8Ej6zmYbflttlBOLAD9Ywb0b1iSLpD1CV+eht/66f3KrKWg== + codemirror@^5.41.0: version "5.51.0" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.51.0.tgz#7746caaf5223e68f5c55ea11e2f3cc82a9a3929e" diff --git a/handlers/dashboard.go b/handlers/dashboard.go index 0a472d11..be1296a3 100644 --- a/handlers/dashboard.go +++ b/handlers/dashboard.go @@ -17,6 +17,7 @@ package handlers import ( "bytes" + "encoding/json" "fmt" "github.com/dgrijalva/jwt-go" "github.com/hunterlong/statping/core" @@ -24,6 +25,7 @@ import ( "github.com/hunterlong/statping/source" "github.com/hunterlong/statping/utils" "net/http" + "os" "strconv" "time" ) @@ -79,6 +81,91 @@ func logsHandler(w http.ResponseWriter, r *http.Request) { returnJson(logs, w, r) } +type themeApi struct { + Directory string `json:"directory,omitempty"` + Base string `json:"base"` + Variables string `json:"variables"` + Mobile string `json:"mobile"` +} + +func apiThemeHandler(w http.ResponseWriter, r *http.Request) { + var base, variables, mobile, dir string + assets := utils.Directory + "/assets" + + if _, err := os.Stat(assets); err == nil { + dir = assets + } + + if dir != "" { + base, _ = utils.OpenFile(dir + "/scss/base.scss") + variables, _ = utils.OpenFile(dir + "/scss/variables.scss") + mobile, _ = utils.OpenFile(dir + "/scss/mobile.scss") + } else { + base, _ = source.TmplBox.String("scss/base.scss") + variables, _ = source.TmplBox.String("scss/variables.scss") + mobile, _ = source.TmplBox.String("scss/mobile.scss") + } + + resp := &themeApi{ + Directory: dir, + Base: base, + Variables: variables, + Mobile: mobile, + } + returnJson(resp, w, r) +} + +func apiThemeSaveHandler(w http.ResponseWriter, r *http.Request) { + var themes themeApi + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&themes) + if err != nil { + sendErrorJson(err, w, r) + return + } + if err := source.SaveAsset([]byte(themes.Base), utils.Directory+"/assets/scss/base.scss"); err != nil { + sendErrorJson(err, w, r) + return + } + if err := source.SaveAsset([]byte(themes.Variables), utils.Directory+"/assets/scss/variables.scss"); err != nil { + sendErrorJson(err, w, r) + return + } + if err := source.SaveAsset([]byte(themes.Mobile), utils.Directory+"/assets/scss/mobile.scss"); err != nil { + sendErrorJson(err, w, r) + return + } + if err := source.CompileSASS(utils.Directory); err != nil { + sendErrorJson(err, w, r) + return + } + resetRouter() + sendJsonAction(themes, "saved", w, r) +} + +func apiThemeCreateHandler(w http.ResponseWriter, r *http.Request) { + dir := utils.Directory + utils.Log.Infof("creating assets in folder: %s/%s", dir, "assets") + if err := source.CreateAllAssets(dir); err != nil { + log.Errorln(err) + sendErrorJson(err, w, r) + return + } + if err := source.CompileSASS(dir); err != nil { + source.CopyToPublic(source.TmplBox, dir+"/assets/css", "base.css") + log.Errorln("Default 'base.css' was inserted because SASS did not work.") + } + resetRouter() + sendJsonAction(dir+"/assets", "created", w, r) +} + +func apiThemeRemoveHandler(w http.ResponseWriter, r *http.Request) { + if err := source.DeleteAllAssets(utils.Directory); err != nil { + log.Errorln(fmt.Errorf("error deleting all assets %v", err)) + } + sendJsonAction(utils.Directory+"/assets", "deleted", w, r) +} + func logsLineHandler(w http.ResponseWriter, r *http.Request) { if lastLine := utils.GetLastLine(); lastLine != nil { w.Header().Set("Content-Type", "text/plain") diff --git a/handlers/routes.go b/handlers/routes.go index ff191e74..187889e2 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -83,6 +83,12 @@ func Router() *mux.Router { r.Handle("/api/logs", authenticated(logsHandler, false)).Methods("GET") r.Handle("/api/logs/last", authenticated(logsLineHandler, false)).Methods("GET") + // API SCSS and ASSETS Routes + r.Handle("/api/theme", authenticated(apiThemeHandler, false)).Methods("GET") + r.Handle("/api/theme", authenticated(apiThemeSaveHandler, false)).Methods("POST") + r.Handle("/api/theme/create", authenticated(apiThemeCreateHandler, false)).Methods("GET") + r.Handle("/api/theme", authenticated(apiThemeRemoveHandler, false)).Methods("DELETE") + // API INTEGRATIONS Routes r.Handle("/api/integrations", authenticated(apiAllIntegrationsHandler, false)).Methods("GET") r.Handle("/api/integrations/{name}", authenticated(apiIntegrationViewHandler, false)).Methods("GET") diff --git a/handlers/settings.go b/handlers/settings.go index 5689dd0f..4aa1c1a1 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -81,9 +81,9 @@ func saveSASSHandler(w http.ResponseWriter, r *http.Request) { theme := form.Get("theme") variables := form.Get("variables") mobile := form.Get("mobile") - source.SaveAsset([]byte(theme), utils.Directory, "scss/base.scss") - source.SaveAsset([]byte(variables), utils.Directory, "scss/variables.scss") - source.SaveAsset([]byte(mobile), utils.Directory, "scss/mobile.scss") + source.SaveAsset([]byte(theme), utils.Directory+"/assets/scss/base.scss") + source.SaveAsset([]byte(variables), utils.Directory+"/assets/scss/variables.scss") + source.SaveAsset([]byte(mobile), utils.Directory+"/assets/scss/mobile.scss") source.CompileSASS(utils.Directory) resetRouter() ExecuteResponse(w, r, "settings.gohtml", core.CoreApp, "settings") diff --git a/source/source.go b/source/source.go index 03b090c4..3091cc80 100644 --- a/source/source.go +++ b/source/source.go @@ -25,6 +25,7 @@ import ( "github.com/russross/blackfriday/v2" "io/ioutil" "os" + "path/filepath" ) var ( @@ -59,15 +60,15 @@ func CompileSASS(folder string) error { stdout, stderr, err := utils.Command(command) - if stdout != "" || stderr != "" { - log.Errorln(fmt.Sprintf("Failed to compile assets with SASS %v", err)) - return errors.New("failed to capture stdout or stderr") - } - if err != nil { log.Errorln(fmt.Sprintf("Failed to compile assets with SASS %v", err)) log.Errorln(fmt.Sprintf("sh -c %v", command)) - return err + return fmt.Errorf("failed to compile assets with SASS: %v %v \n%v", err, stdout, stderr) + } + + if stdout != "" || stderr != "" { + log.Errorln(fmt.Sprintf("Failed to compile assets with SASS %v %v %v", err, stdout, stderr)) + return errors.New("failed to capture stdout or stderr") } log.Infoln(fmt.Sprintf("out: %v | error: %v", stdout, stderr)) @@ -96,8 +97,7 @@ func UsingAssets(folder string) bool { } // SaveAsset will save an asset to the '/assets/' folder. -func SaveAsset(data []byte, folder, file string) error { - location := folder + "/assets/" + file +func SaveAsset(data []byte, location string) error { log.Infoln(fmt.Sprintf("Saving %v", location)) err := utils.SaveFile(location, data) if err != nil { @@ -120,14 +120,20 @@ func OpenAsset(folder, file string) string { // CreateAllAssets will dump HTML, CSS, SCSS, and JS assets into the '/assets' directory func CreateAllAssets(folder string) error { log.Infoln(fmt.Sprintf("Dump Statping assets into %v/assets", folder)) - MakePublicFolder(folder + "/assets") - MakePublicFolder(folder + "/assets/js") - MakePublicFolder(folder + "/assets/css") - MakePublicFolder(folder + "/assets/scss") - MakePublicFolder(folder + "/assets/font") - MakePublicFolder(folder + "/assets/files") + fp := filepath.Join + + MakePublicFolder(fp(folder, "/assets")) + MakePublicFolder(fp(folder, "assets", "js")) + MakePublicFolder(fp(folder, "assets", "css")) + MakePublicFolder(fp(folder, "assets", "scss")) + MakePublicFolder(fp(folder, "assets", "font")) + MakePublicFolder(fp(folder, "assets", "files")) log.Infoln("Inserting scss, css, and javascript files into assets folder") - CopyAllToPublic(TmplBox, folder+"/assets") + + if err := CopyAllToPublic(TmplBox, fp(folder, "assets")); err != nil { + log.Errorln(err) + } + CopyToPublic(TmplBox, folder+"/assets", "robots.txt") CopyToPublic(TmplBox, folder+"/assets", "banner.png") CopyToPublic(TmplBox, folder+"/assets", "favicon.ico") @@ -153,20 +159,32 @@ func DeleteAllAssets(folder string) error { // CopyAllToPublic will copy all the files in a rice box into a local folder func CopyAllToPublic(box *rice.Box, folder string) error { + + exclude := map[string]bool{ + "base.gohtml": true, + "index.html": true, + "swagger.json": true, + "postman.json": true, + "grafana.json": true, + } + err := box.Walk("/", func(path string, info os.FileInfo, err error) error { if info.Name() == "" { return nil } - if info.IsDir() { - folder := fmt.Sprintf("%v/assets/%v/%v", utils.Directory, folder, info.Name()) - return MakePublicFolder(folder) + if exclude[info.Name()] { + return nil } + if info.IsDir() { + return nil + } + utils.Log.Infoln(path) file, err := box.Bytes(path) if err != nil { return err } - filePath := fmt.Sprintf("%v/%v", folder, path) - return SaveAsset(file, utils.Directory, filePath) + filePath := filepath.Join(folder, path) + return SaveAsset(file, filePath) }) return err } diff --git a/utils/utils.go b/utils/utils.go index 703e5e44..2819ecb8 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -223,6 +223,11 @@ func FolderExists(folder string) bool { return false } +func OpenFile(filePath string) (string, error) { + data, err := ioutil.ReadFile(filePath) + return string(data), err +} + // CopyFile will copy a file to a new directory // CopyFile("source.jpg", "/tmp/source.jpg") func CopyFile(src, dst string) error { @@ -262,7 +267,10 @@ func Command(cmd string) (string, string, error) { var errStdout, errStderr error stdoutIn, _ := testCmd.StdoutPipe() stderrIn, _ := testCmd.StderrPipe() - testCmd.Start() + err := testCmd.Start() + if err != nil { + return "", "", err + } go func() { stdout, errStdout = copyAndCapture(os.Stdout, stdoutIn) @@ -272,13 +280,13 @@ func Command(cmd string) (string, string, error) { stderr, errStderr = copyAndCapture(os.Stderr, stderrIn) }() - err := testCmd.Wait() + err = testCmd.Wait() if err != nil { - return "", "", err + return string(stdout), string(stderr), err } if errStdout != nil || errStderr != nil { - return "", "", errors.New("failed to capture stdout or stderr") + return string(stdout), string(stderr), errors.New("failed to capture stdout or stderr") } outStr, errStr := string(stdout), string(stderr) @@ -327,7 +335,7 @@ func DurationReadable(d time.Duration) string { // SaveFile will create a new file with data inside it // SaveFile("newfile.json", []byte('{"data": "success"}') func SaveFile(filename string, data []byte) error { - err := ioutil.WriteFile(filename, data, 0644) + err := ioutil.WriteFile(filename, data, 0655) return err }