diff --git a/.forgejo/workflows/Dockerfile b/.forgejo/workflows/Dockerfile deleted file mode 100644 index 82c8879..0000000 --- a/.forgejo/workflows/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 8f62b00..0000000 --- a/.forgejo/workflows/build.yaml +++ /dev/null @@ -1,123 +0,0 @@ -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 edcb24b..c128eff 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -234,8 +234,6 @@ linters: - wrapcheck - nonamedreturns - gomnd - - depguard - - gosmopolitan enable-all: true fast: false diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..d431220 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..b9d6e3f --- /dev/null +++ b/BUILD.md @@ -0,0 +1,30 @@ +# 👷 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 f6f7e04..47f94d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,10 @@ COPY src /app/src COPY go.mod /app/ COPY .golangci.yml /app/ -RUN go mod tidy; \ - golangci-lint run; \ - go test ./src/... +RUN go mod tidy +RUN golangci-lint run +RUN go test ./src/... + RUN CGO_ENABLED=1 go build -o app src/main/main.go FROM alpine:latest @@ -20,9 +21,6 @@ WORKDIR /app COPY --from=1 /app/app . COPY ./templates /app/templates +RUN touch config.json database.db -RUN mkdir data -ENV DATABASE_PATH=./data/database.db -ENV CONFIG_PATH=./data/config.json - -CMD "./app" \ No newline at end of file +CMD ["./app"] \ No newline at end of file diff --git a/README.md b/README.md index 40c1bc7..f3c9f66 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,21 @@ # 🌿 EcoDash -[![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) +[![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) 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://ecodash.massivebox.net +You can see it in action here: https://demo.ecodash.xyz ## Get started -Check out the documentation in our [wiki](https://git.massivebox.net/massivebox/ecodash/wiki) to get started with EcoDash. +Check out the documentation in our [website](https://ecodash.xyz) to get started with EcoDash. -- [📖 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) +- [📖 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) ## License diff --git a/src/ecodash/config.go b/src/ecodash/config.go index b183666..91b7541 100644 --- a/src/ecodash/config.go +++ b/src/ecodash/config.go @@ -4,7 +4,6 @@ import ( "database/sql" "encoding/json" "errors" - "html/template" "os" "reflect" "regexp" @@ -38,17 +37,10 @@ type Administrator struct { PasswordHash string `json:"password_hash"` } type Dashboard struct { - 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"` + Name string `json:"name"` + Theme string `json:"theme"` + FooterLinks []Link `json:"footer_links"` + HeaderLinks []Link `json:"header_links"` } var errBadHAFormat = errors.New("HomeAssistant base URL is badly formatted") @@ -69,14 +61,10 @@ func formatURL(url string) (string, error) { return url, nil } -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) +func LoadConfig() (config *Config, isFirstRun bool, err error) { + db, err := sql.Open("sqlite", "./database.db") if err != nil { - return &Config{}, err + return &Config{}, false, err } _, err = db.Exec(`CREATE TABLE IF NOT EXISTS "cache" ( @@ -86,7 +74,7 @@ func LoadConfig() (config *Config, err error) { PRIMARY KEY("time") );`) if err != nil { - return &Config{}, err + return &Config{}, false, err } defaultConfig := &Config{} @@ -103,32 +91,28 @@ func LoadConfig() (config *Config, err error) { }) defaultConfig.db = db - var confPath string - if confPath = os.Getenv("CONFIG_PATH"); confPath == "" { - confPath = "./config.json" - } - data, err := os.ReadFile(confPath) + data, err := os.ReadFile("config.json") if err != nil { // if the data file doesn't exist, we consider it a first run if os.IsNotExist(err) { - return defaultConfig, nil + return defaultConfig, true, nil } - return &Config{}, err + return &Config{}, false, err } // if the data file is empty, we consider it as a first run if len(data) == 0 { - return defaultConfig, nil + return defaultConfig, true, nil } conf := &Config{} err = json.Unmarshal(data, &conf) if err != nil { - return &Config{}, err + return &Config{}, false, err } conf.db = db - return conf, nil + return conf, false, nil } func (config *Config) IsAuthorized(c *fiber.Ctx) bool { diff --git a/src/ecodash/database.go b/src/ecodash/database.go index f580b15..0c44e35 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 historyPolledSmartEnergySummation { + for key, day := range greenEnergyPercentage { 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 02560ae..7c32f2a 100644 --- a/src/ecodash/http.go +++ b/src/ecodash/http.go @@ -47,18 +47,16 @@ 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 { - // #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: "An error occurred!", + Body: html.EscapeString(err.Error()), }) } - 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", + 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, }) } @@ -66,28 +64,38 @@ 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, nil) + return config.RenderAdminPanel(c) } return c.Render("login", fiber.Map{"Defaults": config.getTemplateDefaults(), "Failed": true}, "base") } if config.IsAuthorized(c) { - return config.RenderAdminPanel(c, nil) + return config.RenderAdminPanel(c) } return c.Render("login", config.TemplateDefaultsMap(), "base") } -func (config *Config) RenderAdminPanel(c *fiber.Ctx, message *MessageCard) error { +func (config *Config) RenderAdminPanel(c *fiber.Ctx, warning ...Warning) 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") } @@ -109,11 +117,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 /*MessageCard to be filled later*/}, + Dashboard: Dashboard{Theme: c.FormValue("theme"), Name: c.FormValue("name"), HeaderLinks: config.Dashboard.HeaderLinks, FooterLinks: config.Dashboard.FooterLinks}, } if c.FormValue("keep_old_password") == "" { @@ -122,15 +130,6 @@ 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 @@ -152,17 +151,11 @@ func (config *Config) saveAdminForm(c *fiber.Ctx) error { return err } - *config = form - - var confPath string - if confPath = os.Getenv("CONFIG_PATH"); confPath == "" { - confPath = "./config.json" - } - return os.WriteFile(confPath, js, 0o600) + return os.WriteFile("config.json", js, 0o600) } func averageExcludingCurrentDay(data []float32) float32 { - if len(data) <= 1 { + if len(data) == 0 { return 0 } data = data[:len(data)-1] @@ -208,6 +201,5 @@ 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 52e0854..4fcba7a 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -2,7 +2,9 @@ package main import ( "log" + "net/http" "os" + "time" "git.massivebox.net/ecodash/ecodash/src/ecodash" "git.massivebox.net/ecodash/ecodash/src/tools" @@ -12,18 +14,20 @@ import ( ) func main() { - config, err := ecodash.LoadConfig() + config, isFirstRun, err := ecodash.LoadConfig() if err != nil { log.Fatal(err) } - cr := cron.New() - _, err = cr.AddFunc("@hourly", config.UpdateHistory) - 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.Start() - config.UpdateHistory() engine := html.New("./templates/"+config.Dashboard.Theme, ".html") engine.AddFunc("divide", tools.TemplateDivide) @@ -36,10 +40,10 @@ func main() { app.Static("/assets", "./templates/"+config.Dashboard.Theme+"/assets") app.Get("/", func(c *fiber.Ctx) error { - if config.Administrator.Username == "" || config.Administrator.PasswordHash == "" { + if isFirstRun { c.Cookie(&fiber.Cookie{Name: "admin_username", Value: ""}) c.Cookie(&fiber.Cookie{Name: "admin_password_hash", Value: tools.Hash("")}) - return config.RenderAdminPanel(c, nil) + return config.RenderAdminPanel(c) } return config.RenderIndex(c) }) @@ -50,6 +54,17 @@ 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 3bac1b1..47ab613 100644 --- a/src/tools/tools.go +++ b/src/tools/tools.go @@ -17,10 +17,6 @@ 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 d1e889f..14654a2 100644 --- a/templates/default/admin.html +++ b/templates/default/admin.html @@ -4,15 +4,15 @@ Documentation

-{{if .Message}} -
-
-

{{.Message.Title}}

-
- -
+{{if .Warning}} +
+
+

{{.Warning.Header}}

+
+ +
{{end}}
@@ -45,16 +45,6 @@ - - -
diff --git a/templates/default/assets/custom.css b/templates/default/assets/custom.css index c80367c..1184003 100644 --- a/templates/default/assets/custom.css +++ b/templates/default/assets/custom.css @@ -46,14 +46,4 @@ 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 cafaa3e..5115c01 100644 --- a/templates/default/index.html +++ b/templates/default/index.html @@ -1,16 +1,5 @@ -{{if .MOTD}} -
-
-

{{.MOTD.Title}}

-
- -
-{{end}} -

Green report

@@ -18,6 +7,7 @@ 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 new file mode 100644 index 0000000..2a9ca60 --- /dev/null +++ b/templates/default/restart.html @@ -0,0 +1,6 @@ +

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