diff --git a/.forgejo/workflows/Dockerfile b/.forgejo/workflows/Dockerfile new file mode 100644 index 0000000..82c8879 --- /dev/null +++ b/.forgejo/workflows/Dockerfile @@ -0,0 +1,10 @@ +FROM debian:latest + +WORKDIR /app +COPY ecodash_arm ecodash_arm +COPY ecodash_x86 ecodash_x86 +COPY templates templates + +RUN if [ "$(uname -m)" = "aarch64" ]; then mv ecodash_arm app; rm ecodash_x86; else mv ecodash_x86 app; rm ecodash_arm; fi + +CMD ["./app"] diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml new file mode 100644 index 0000000..8f62b00 --- /dev/null +++ b/.forgejo/workflows/build.yaml @@ -0,0 +1,123 @@ +name: CI Pipeline + +on: + push: + branches: + - master + tags: + - 'v*' + +jobs: + + build: + runs-on: ubuntu-22.04 + steps: + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.22' + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build + run: | + go mod tidy + echo Building for linux/amd64... + GOOS=linux GOARCH=amd64 go build -o ecodash_x86 src/main/main.go + echo Building for linux/arm... + GOOS=linux GOARCH=arm go build -o ecodash_arm src/main/main.go + + - name: Stash artifacts + uses: actions/upload-artifact@v3 + with: + path: | + ecodash_x86 + ecodash_arm + + build-and-push-docker: + runs-on: ubuntu-22.04 + needs: build + steps: + + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: https://github.com/docker/metadata-action@v5 + with: + images: git.massivebox.net/massivebox/ecodash + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern=v{{major}} + type=semver,pattern={{major}}.{{minor}} + + - name: Install QEMU + run: sudo apt-get update && sudo apt-get install -y qemu-user-static + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: git.massivebox.net + username: ${{ github.actor }} + password: ${{ secrets.FORGE_TOKEN }} + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: artifact + - name: Move dockerfile + run: mv .forgejo/workflows/Dockerfile Dockerfile + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + + publish-executables: + runs-on: ubuntu-22.04 + needs: build + steps: + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: artifact + + - name: Prepare build artifacts + run: | + mkdir release + mv ecodash_x86 ecodash + zip -r release/ecodash-x86.zip templates ecodash + mv ecodash_arm ecodash + zip -r release/ecodash-arm.zip templates ecodash + + - name: Upload artifacts to CI + uses: actions/upload-artifact@v3 + with: + path: | + ecodash_x86 + ecodash_arm + templates + overwrite: true + + - name: Create release + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + uses: actions/forgejo-release@v1 + with: + direction: upload + url: https://git.massivebox.net + repo: massivebox/ecodash + release-dir: release + tag: ${{ github.ref_name }} + token: ${{ secrets.FORGE_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml index c128eff..edcb24b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -234,6 +234,8 @@ linters: - wrapcheck - nonamedreturns - gomnd + - depguard + - gosmopolitan enable-all: true fast: false diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index d431220..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,45 +0,0 @@ -pipeline: - - docker: - image: woodpeckerci/plugin-docker-buildx - settings: - registry: git.massivebox.net - repo: git.massivebox.net/ecodash/ecodash - platforms: linux/amd64,linux/arm64 - auto_tag: true - username: massivebox - password: - from_secret: auth_token - when: - event: tag - - build: - image: golang - commands: - - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin - - go mod tidy - - golangci-lint run - - go build -o ecodash-x86 src/main/main.go - - env GOOS=linux GOARCH=arm go build -o ecodash-arm src/main/main.go - - prepare-gitea-release: - image: alpine - commands: - - apk update; apk add zip - - mv ecodash-x86 ecodash; zip -r ecodash-x86.zip templates ecodash - - mv ecodash-arm ecodash; zip -r ecodash-arm.zip templates ecodash - when: - event: tag - - gitea-publish: - image: plugins/gitea-release - settings: - base_url: https://git.massivebox.net - files: - - ecodash-x86.zip - - ecodash-arm.zip - api_key: - from_secret: auth_token - title: ${CI_COMMIT_TAG} - when: - event: tag diff --git a/BUILD.md b/BUILD.md deleted file mode 100644 index b9d6e3f..0000000 --- a/BUILD.md +++ /dev/null @@ -1,30 +0,0 @@ -# 👷 Building EcoDash - -Here's how to build EcoDash in both binaries and as a Docker container. This is not necessary for most cases - we provide both pre-built binaries and containers for Linux ARM and x86_64 - however in devices with unsupported architectures it's necessary. - -You're encouraged to first check the installation instructions to see if a pre-built container or binary is already available. -If you really have to build it yourself, we recommend you Docker over binaries. - -## Binaries - -### Linux - -1. Download the Go Compiler from https://go.dev/dl/ or from your repository's package manager (it's usually called `go` or `golang`) -2. Download the Git SCM from https://git-scm.com/download/linux or from your package manager (it's always called `git`) -3. Download `golangci-lint` from https://golangci-lint.run/ -4. Clone the repository by running `git clone https://gitea.massivebox.net/ecodash/ecodash.git ` inside a command prompt -5. Switch to the project directory with `cd ecodash` -6. Run `golangci-lint run` to lint all project files -7. Build with `go build src/main/main.go -o ecodash`. This will generate an executable, `ecodash`, in the same directory. - -### Windows - -1. Install the latest release of the Go Compiler for Windows from https://go.dev/dl/ -2. Install the Git SCM from https://git-scm.com/download/win. The "Standalone installer" is recommended. All the default settings will work fine. -3. Download `golangci-lint` from https://golangci-lint.run/ -4. Clone the repository by running `git clone https://gitea.massivebox.net/ecodash/ecodash.git ` inside a command prompt -5. Switch to the project directory with `cd ecodash` -6. Run `golangci-lint run` to lint all project files -7. Build with `go build src/main/main.go -o ecodash`. This will generate an executable, `ecodash.exe`, in the same directory. - -## Docker \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 47f94d0..f6f7e04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,9 @@ COPY src /app/src COPY go.mod /app/ COPY .golangci.yml /app/ -RUN go mod tidy -RUN golangci-lint run -RUN go test ./src/... - +RUN go mod tidy; \ + golangci-lint run; \ + go test ./src/... RUN CGO_ENABLED=1 go build -o app src/main/main.go FROM alpine:latest @@ -21,6 +20,9 @@ WORKDIR /app COPY --from=1 /app/app . COPY ./templates /app/templates -RUN touch config.json database.db -CMD ["./app"] \ No newline at end of file +RUN mkdir data +ENV DATABASE_PATH=./data/database.db +ENV CONFIG_PATH=./data/config.json + +CMD "./app" \ No newline at end of file diff --git a/README.md b/README.md index f3c9f66..40c1bc7 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,19 @@ # 🌿 EcoDash -[![status-badge](https://woodpecker.massivebox.net/api/badges/ecodash/ecodash/status.svg)](https://woodpecker.massivebox.net/ecodash/ecodash) [![Visit our website](https://cloud.massivebox.net/api/public/dl/yEzoZyW8?inline=true)](https://ecodash.xyz) [![Support the project](https://cloud.massivebox.net/api/public/dl/dthbBylL?inline=true)](https://ecodash.xyz/contribute) +[![Support the project](https://cloud.massivebox.net/index.php/s/DcxB6KwkDZALbXw/download?path=%2FEcoDash&files=support-the-project.svg)](https://massivebox.net/pages/donate.html) EcoDash is a simple way to show your users how much your server consumes. It's intended as a medium of transparency, that gives your users an idea about the consumption of your machine. It's not meant to be 100% accurate. -You can see it in action here: https://demo.ecodash.xyz +You can see it in action here: https://ecodash.massivebox.net ## Get started -Check out the documentation in our [website](https://ecodash.xyz) to get started with EcoDash. +Check out the documentation in our [wiki](https://git.massivebox.net/massivebox/ecodash/wiki) to get started with EcoDash. -- [📖 Introduction](https://ecodash.xyz/docs) -- [🛣 Roadmap](https://ecodash.xyz/docs/roadmap) -- [⬇️ Install](https://ecodash.xyz/docs/install) -- [⚙️ Setup](https://ecodash.xyz/docs/setup) -- [🆘 Support](https://ecodash.xyz/docs/support) +- [📖 Introduction](https://git.massivebox.net/massivebox/ecodash/wiki) +- [⬇️ Installation](https://git.massivebox.net/massivebox/ecodash/wiki/install) +- [⚙️ Configuration](https://git.massivebox.net/massivebox/ecodash/wiki/config) ## License diff --git a/src/ecodash/config.go b/src/ecodash/config.go index 91b7541..b183666 100644 --- a/src/ecodash/config.go +++ b/src/ecodash/config.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "html/template" "os" "reflect" "regexp" @@ -37,10 +38,17 @@ type Administrator struct { PasswordHash string `json:"password_hash"` } type Dashboard struct { - Name string `json:"name"` - Theme string `json:"theme"` - FooterLinks []Link `json:"footer_links"` - HeaderLinks []Link `json:"header_links"` + MOTD *MessageCard `json:"motd"` + Name string `json:"name"` + Theme string `json:"theme"` + FooterLinks []Link `json:"footer_links"` + HeaderLinks []Link `json:"header_links"` +} + +type MessageCard struct { + Title string `json:"title"` + Content template.HTML `json:"content"` + Style string `json:"style"` } var errBadHAFormat = errors.New("HomeAssistant base URL is badly formatted") @@ -61,10 +69,14 @@ func formatURL(url string) (string, error) { return url, nil } -func LoadConfig() (config *Config, isFirstRun bool, err error) { - db, err := sql.Open("sqlite", "./database.db") +func LoadConfig() (config *Config, err error) { + var dbPath string + if dbPath = os.Getenv("DATABASE_PATH"); dbPath == "" { + dbPath = "./database.db" + } + db, err := sql.Open("sqlite", dbPath) if err != nil { - return &Config{}, false, err + return &Config{}, err } _, err = db.Exec(`CREATE TABLE IF NOT EXISTS "cache" ( @@ -74,7 +86,7 @@ func LoadConfig() (config *Config, isFirstRun bool, err error) { PRIMARY KEY("time") );`) if err != nil { - return &Config{}, false, err + return &Config{}, err } defaultConfig := &Config{} @@ -91,28 +103,32 @@ func LoadConfig() (config *Config, isFirstRun bool, err error) { }) defaultConfig.db = db - data, err := os.ReadFile("config.json") + var confPath string + if confPath = os.Getenv("CONFIG_PATH"); confPath == "" { + confPath = "./config.json" + } + data, err := os.ReadFile(confPath) if err != nil { // if the data file doesn't exist, we consider it a first run if os.IsNotExist(err) { - return defaultConfig, true, nil + return defaultConfig, nil } - return &Config{}, false, err + return &Config{}, err } // if the data file is empty, we consider it as a first run if len(data) == 0 { - return defaultConfig, true, nil + return defaultConfig, nil } conf := &Config{} err = json.Unmarshal(data, &conf) if err != nil { - return &Config{}, false, err + return &Config{}, err } conf.db = db - return conf, false, nil + return conf, nil } func (config *Config) IsAuthorized(c *fiber.Ctx) bool { diff --git a/src/ecodash/database.go b/src/ecodash/database.go index 0c44e35..f580b15 100644 --- a/src/ecodash/database.go +++ b/src/ecodash/database.go @@ -75,7 +75,7 @@ func (config *Config) refreshCacheFromPast(pastTime time.Time) error { } defer stmtIgnore.Close() - for key, day := range greenEnergyPercentage { + for key, day := range historyPolledSmartEnergySummation { var stmt *sql.Stmt if greenEnergyPercentage[key].Value != 0 && historyPolledSmartEnergySummation[key].Value != 0 { stmt = stmtReplace diff --git a/src/ecodash/http.go b/src/ecodash/http.go index 7c32f2a..02560ae 100644 --- a/src/ecodash/http.go +++ b/src/ecodash/http.go @@ -47,16 +47,18 @@ func (config *Config) AdminEndpoint(c *fiber.Ctx) error { if config.IsAuthorized(c) { // here the user is submitting the form to change configuration err := config.saveAdminForm(c) if err != nil { - return config.RenderAdminPanel(c, Warning{ - Header: "An error occurred!", - Body: html.EscapeString(err.Error()), + // #nosec the input is admin-defined, and the admin is assumed to be trusted. + return config.RenderAdminPanel(c, &MessageCard{ + Title: "An error occurred!", + Content: template.HTML(html.EscapeString(err.Error())), + Style: "error", }) } - return config.RenderAdminPanel(c, Warning{ - Header: "Restart needed", - Body: "In order to apply changes, please restart EcoDash.
" + - "If you're running via Docker, click here to restart automatically.", - IsSuccess: true, + return config.RenderAdminPanel(c, &MessageCard{ + Title: "Settings applied", + Content: "Your settings have been tested and applied successfully.
" + + "You can continue using EcoDash on the Home.", + Style: "success", }) } @@ -64,38 +66,28 @@ func (config *Config) AdminEndpoint(c *fiber.Ctx) error { if c.FormValue("username") == config.Administrator.Username && tools.Hash(c.FormValue("password")) == config.Administrator.PasswordHash { c.Cookie(&fiber.Cookie{Name: "admin_username", Value: c.FormValue("username")}) c.Cookie(&fiber.Cookie{Name: "admin_password_hash", Value: tools.Hash(c.FormValue("password"))}) - return config.RenderAdminPanel(c) + return config.RenderAdminPanel(c, nil) } return c.Render("login", fiber.Map{"Defaults": config.getTemplateDefaults(), "Failed": true}, "base") } if config.IsAuthorized(c) { - return config.RenderAdminPanel(c) + return config.RenderAdminPanel(c, nil) } return c.Render("login", config.TemplateDefaultsMap(), "base") } -func (config *Config) RenderAdminPanel(c *fiber.Ctx, warning ...Warning) error { +func (config *Config) RenderAdminPanel(c *fiber.Ctx, message *MessageCard) error { dirs, err := os.ReadDir("./templates") if err != nil { return err } - if len(warning) > 0 { - // #nosec // TODO this is dangerous, even if we're escaping the only place where we're passing a non-literal - warning[0].BodyHTML = template.HTML(warning[0].Body) - return c.Render("admin", fiber.Map{ - "Defaults": config.getTemplateDefaults(), - "Themes": dirs, - "Config": config, - "Warning": warning[0], - }, "base") - } - return c.Render("admin", fiber.Map{ "Defaults": config.getTemplateDefaults(), "Themes": dirs, "Config": config, + "Message": message, }, "base") } @@ -117,11 +109,11 @@ func (config *Config) saveAdminForm(c *fiber.Ctx) error { return err } - form := &Config{ + form := Config{ HomeAssistant: HomeAssistant{ /*BaseURL to be filled later*/ APIKey: c.FormValue("api_key"), InstallationDate: dayStart(parsedTime)}, Sensors: Sensors{PolledSmartEnergySummation: c.FormValue("polled_smart_energy_summation"), FossilPercentage: c.FormValue("fossil_percentage")}, Administrator: Administrator{Username: c.FormValue("username") /*PasswordHash to be filled later*/}, - Dashboard: Dashboard{Theme: c.FormValue("theme"), Name: c.FormValue("name"), HeaderLinks: config.Dashboard.HeaderLinks, FooterLinks: config.Dashboard.FooterLinks}, + Dashboard: Dashboard{Theme: c.FormValue("theme"), Name: c.FormValue("name"), HeaderLinks: config.Dashboard.HeaderLinks, FooterLinks: config.Dashboard.FooterLinks /*MessageCard to be filled later*/}, } if c.FormValue("keep_old_password") == "" { @@ -130,6 +122,15 @@ func (config *Config) saveAdminForm(c *fiber.Ctx) error { form.Administrator.PasswordHash = config.Administrator.PasswordHash } + if c.FormValue("motd_title") != "" || c.FormValue("motd_content") != "" { + // #nosec the input is admin-defined, and the admin is assumed to be trusted. + form.Dashboard.MOTD = &MessageCard{ + Title: c.FormValue("motd_title"), + Content: template.HTML(c.FormValue("motd_content")), + Style: c.FormValue("motd_style"), + } + } + fmtURL, err := formatURL(c.FormValue("base_url")) if err != nil { return err @@ -151,11 +152,17 @@ func (config *Config) saveAdminForm(c *fiber.Ctx) error { return err } - return os.WriteFile("config.json", js, 0o600) + *config = form + + var confPath string + if confPath = os.Getenv("CONFIG_PATH"); confPath == "" { + confPath = "./config.json" + } + return os.WriteFile(confPath, js, 0o600) } func averageExcludingCurrentDay(data []float32) float32 { - if len(data) == 0 { + if len(data) <= 1 { return 0 } data = data[:len(data)-1] @@ -201,5 +208,6 @@ func (config *Config) RenderIndex(c *fiber.Ctx) error { "EnergyConsumptions": energyConsumptions, "GreenEnergyPercent": averageExcludingCurrentDay(greenEnergyPercents), "PerDayUsage": perDayUsage, + "MOTD": config.Dashboard.MOTD, }, "base") } diff --git a/src/main/main.go b/src/main/main.go index 4fcba7a..52e0854 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -2,9 +2,7 @@ package main import ( "log" - "net/http" "os" - "time" "git.massivebox.net/ecodash/ecodash/src/ecodash" "git.massivebox.net/ecodash/ecodash/src/tools" @@ -14,20 +12,18 @@ import ( ) func main() { - config, isFirstRun, err := ecodash.LoadConfig() + config, err := ecodash.LoadConfig() if err != nil { log.Fatal(err) } - if !isFirstRun { - cr := cron.New() - _, err = cr.AddFunc("@hourly", config.UpdateHistory) - if err != nil { - log.Fatal(err) - } - cr.Start() - config.UpdateHistory() + cr := cron.New() + _, err = cr.AddFunc("@hourly", config.UpdateHistory) + if err != nil { + log.Fatal(err) } + cr.Start() + config.UpdateHistory() engine := html.New("./templates/"+config.Dashboard.Theme, ".html") engine.AddFunc("divide", tools.TemplateDivide) @@ -40,10 +36,10 @@ func main() { app.Static("/assets", "./templates/"+config.Dashboard.Theme+"/assets") app.Get("/", func(c *fiber.Ctx) error { - if isFirstRun { + if config.Administrator.Username == "" || config.Administrator.PasswordHash == "" { c.Cookie(&fiber.Cookie{Name: "admin_username", Value: ""}) c.Cookie(&fiber.Cookie{Name: "admin_password_hash", Value: tools.Hash("")}) - return config.RenderAdminPanel(c) + return config.RenderAdminPanel(c, nil) } return config.RenderIndex(c) }) @@ -54,17 +50,6 @@ func main() { app.All("/admin", config.AdminEndpoint) - app.Get("/restart", func(c *fiber.Ctx) error { - if config.IsAuthorized(c) { - go func() { - time.Sleep(time.Second) - os.Exit(1) - }() - return c.Render("restart", config.TemplateDefaultsMap(), "base") - } - return c.Redirect("./", http.StatusTemporaryRedirect) - }) - port := os.Getenv("PORT") if port == "" { port = "80" diff --git a/src/tools/tools.go b/src/tools/tools.go index 47ab613..3bac1b1 100644 --- a/src/tools/tools.go +++ b/src/tools/tools.go @@ -17,6 +17,10 @@ func Hash(toHash string) string { func TemplateDivide(num1, num2 float32) template.HTML { division := float64(num1 / num2) + if math.IsNaN(division) || division == 0 { + return "0" + } + powerOfTen := int(math.Floor(math.Log10(division))) if powerOfTen >= -2 && powerOfTen <= 2 { // #nosec G203 // We're only printing floats diff --git a/templates/default/admin.html b/templates/default/admin.html index 14654a2..d1e889f 100644 --- a/templates/default/admin.html +++ b/templates/default/admin.html @@ -4,15 +4,15 @@ Documentation

-{{if .Warning}} -
-
-

{{.Warning.Header}}

-
- -
+{{if .Message}} +
+
+

{{.Message.Title}}

+
+ +
{{end}}
@@ -45,6 +45,16 @@ + + +
diff --git a/templates/default/assets/custom.css b/templates/default/assets/custom.css index 1184003..c80367c 100644 --- a/templates/default/assets/custom.css +++ b/templates/default/assets/custom.css @@ -46,4 +46,14 @@ svg, footer img { width: 100% } fill: white; } +} + +.success { + background-color: #008000; color: white +} +.warning { + background-color: #807a00; color: white +} +.error { + background-color: #800000; color: white } \ No newline at end of file diff --git a/templates/default/index.html b/templates/default/index.html index 5115c01..cafaa3e 100644 --- a/templates/default/index.html +++ b/templates/default/index.html @@ -1,5 +1,16 @@ +{{if .MOTD}} +
+
+

{{.MOTD.Title}}

+
+ +
+{{end}} +

Green report

@@ -7,7 +18,6 @@ This server's energy statistics for the last eight days (current day included)

-
diff --git a/templates/default/restart.html b/templates/default/restart.html deleted file mode 100644 index 2a9ca60..0000000 --- a/templates/default/restart.html +++ /dev/null @@ -1,6 +0,0 @@ -

Restarting...

-

- You should be able to continue using EcoDash soon by clicking here.
- If you get an error like "Address Unreachable", make sure you've allowed your container to restart automatically.
- Check the error logs if the error persists. -

\ No newline at end of file