diff --git a/.github/release-drafter-monitor.yml b/.github/release-drafter-monitor.yml new file mode 100644 index 00000000..e6775071 --- /dev/null +++ b/.github/release-drafter-monitor.yml @@ -0,0 +1,50 @@ +name-template: 'Monitor - v$RESOLVED_VERSION' +tag-template: 'monitor/v$RESOLVED_VERSION' +tag-prefix: monitor/v +include-paths: + - monitor +categories: + - title: 'โ— Breaking Changes' + labels: + - 'โ— BreakingChange' + - title: '๐Ÿš€ New' + labels: + - 'โœ๏ธ Feature' + - title: '๐Ÿงน Updates' + labels: + - '๐Ÿงน Updates' + - '๐Ÿค– Dependencies' + - title: '๐Ÿ› Fixes' + labels: + - 'โ˜ข๏ธ Bug' + - title: '๐Ÿ“š Documentation' + labels: + - '๐Ÿ“’ Documentation' +change-template: '- $TITLE (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. +exclude-contributors: + - dependabot + - dependabot[bot] +version-resolver: + major: + labels: + - 'major' + - 'โ— BreakingChange' + minor: + labels: + - 'minor' + - 'โœ๏ธ Feature' + patch: + labels: + - 'patch' + - '๐Ÿ“’ Documentation' + - 'โ˜ข๏ธ Bug' + - '๐Ÿค– Dependencies' + - '๐Ÿงน Updates' + default: patch +template: | + $CHANGES + + **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...monitor/v$RESOLVED_VERSION + + Thank you $CONTRIBUTORS for making this update possible. diff --git a/.github/workflows/release-drafter-monitor.yml b/.github/workflows/release-drafter-monitor.yml new file mode 100644 index 00000000..db8df581 --- /dev/null +++ b/.github/workflows/release-drafter-monitor.yml @@ -0,0 +1,19 @@ +name: Release Drafter Monitor +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - master + - main + paths: + - 'monitor/**' +jobs: + draft_release_casbin: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: release-drafter/release-drafter@v5 + with: + config-name: release-drafter-monitor.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-monitor.yml b/.github/workflows/test-monitor.yml new file mode 100644 index 00000000..6a33d883 --- /dev/null +++ b/.github/workflows/test-monitor.yml @@ -0,0 +1,31 @@ +name: "Test Monitor" + +on: + push: + branches: + - master + - main + paths: + - 'monitor/**' + pull_request: + paths: + - 'monitor/**' + +jobs: + Tests: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: + - 1.22.x + - 1.23.x + steps: + - name: Fetch Repository + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '${{ matrix.go-version }}' + - name: Run Test + working-directory: ./monitor + run: go test -v -race ./... diff --git a/README.md b/README.md index 96086331..264208e1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Repository for third party middlewares with dependencies. * [JWT](./jwt/README.md) * [Loadshed](./loadshed/README.md) * [NewRelic](./fibernewrelic/README.md) +* [Monitor](./monitor/README.md) * [Open Policy Agent](./opafiber/README.md) * [Otelfiber (OpenTelemetry)](./otelfiber/README.md) * [Paseto](./paseto/README.md) diff --git a/monitor/README.md b/monitor/README.md new file mode 100644 index 00000000..0dd78fc7 --- /dev/null +++ b/monitor/README.md @@ -0,0 +1,84 @@ +--- +id: monitor +--- + +# Monitor + +![Release](https://img.shields.io/github/v/tag/gofiber/contrib?filter=monitor*) +![Discord](https://img.shields.io/discord/704680098577514527?style=flat&label=%F0%9F%92%AC%20discord&color=00ACD7) +![Test](https://github.com/gofiber/contrib/workflows/Tests/badge.svg) +![Security](https://github.com/gofiber/contrib/workflows/Security/badge.svg) +![Linter](https://github.com/gofiber/contrib/workflows/Linter/badge.svg) + +Monitor middleware for [Fiber](https://github.com/gofiber/fiber) that reports server metrics, inspired by [express-status-monitor](https://github.com/RafalWilinski/express-status-monitor) + +![](https://i.imgur.com/nHAtBpJ.gif) + +## Install + +This middleware supports Fiber v3. + +``` +go get -u github.com/gofiber/fiber/v3 +go get -u github.com/gofiber/contrib/monitor +``` + +### Signature + +```go +monitor.New(config ...monitor.Config) fiber.Handler +``` + +### Config + +| Property | Type | Description | Default | +| :--------- | :------------------------ | :----------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- | +| Title | `string` | Metrics page title. | `Fiber Monitor` | +| Refresh | `time.Duration` | Refresh period. | `3 seconds` | +| APIOnly | `bool` | Whether the service should expose only the montioring API. | `false` | +| Next | `func(c *fiber.Ctx) bool` | Define a function to add custom fields. | `nil` | +| CustomHead | `string` | Custom HTML code to Head Section(Before End). | `empty` | +| FontURL | `string` | FontURL for specilt font resource path or URL. also you can use relative path. | `https://fonts.googleapis.com/css2?family=Roboto:wght@400;900&display=swap` | +| ChartJsURL | `string` | ChartJsURL for specilt chartjs library, path or URL, also you can use relative path. | `https://cdn.jsdelivr.net/npm/chart.js@2.9/dist/Chart.bundle.min.js` | + +### Example + +```go +package main + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/contrib/monitor" +) + +func main() { + app := fiber.New() + + // Initialize default config (Assign the middleware to /metrics) + app.Get("/metrics", monitor.New()) + + // Or extend your config for customization + // Assign the middleware to /metrics + // and change the Title to `MyService Metrics Page` + app.Get("/metrics", monitor.New(monitor.Config{Title: "MyService Metrics Page"})) + + log.Fatal(app.Listen(":3000")) +} +``` + + +## Default Config + +```go +var ConfigDefault = Config{ + Title: defaultTitle, + Refresh: defaultRefresh, + FontURL: defaultFontURL, + ChartJsURL: defaultChartJSURL, + CustomHead: defaultCustomHead, + APIOnly: false, + Next: nil, +} +``` diff --git a/monitor/config.go b/monitor/config.go new file mode 100644 index 00000000..048563a4 --- /dev/null +++ b/monitor/config.go @@ -0,0 +1,132 @@ +package monitor + +import ( + "time" + + "github.com/gofiber/fiber/v3" +) + +// Config defines the config for middleware. +type Config struct { + // Metrics page title + // + // Optional. Default: "Fiber Monitor" + Title string + + // Refresh period + // + // Optional. Default: 3 seconds + Refresh time.Duration + + // Whether the service should expose only the monitoring API. + // + // Optional. Default: false + APIOnly bool + + // Next defines a function to skip this middleware when returned true. + // + // Optional. Default: nil + Next func(c *fiber.Ctx) bool + + // Custom HTML Code to Head Section(Before End) + // + // Optional. Default: empty + CustomHead string + + // FontURL to specify font resource path or URL. You can also use a relative path. + // + // Optional. Default: https://fonts.googleapis.com/css2?family=Roboto:wght@400;900&display=swap + FontURL string + + // ChartJSURL to specify ChartJS library path or URL. You can also use a relative path. + // + // Optional. Default: https://cdn.jsdelivr.net/npm/chart.js@2.9/dist/Chart.bundle.min.js + ChartJSURL string + + index string +} + +var ConfigDefault = Config{ + Title: defaultTitle, + Refresh: defaultRefresh, + FontURL: defaultFontURL, + ChartJSURL: defaultChartJSURL, + CustomHead: defaultCustomHead, + APIOnly: false, + Next: nil, + index: newIndex(viewBag{ + defaultTitle, + defaultRefresh, + defaultFontURL, + defaultChartJSURL, + defaultCustomHead, + }), +} + +func configDefault(config ...Config) Config { + // Users can change ConfigDefault.Title/Refresh which then + // become incompatible with ConfigDefault.index + if ConfigDefault.Title != defaultTitle || + ConfigDefault.Refresh != defaultRefresh || + ConfigDefault.FontURL != defaultFontURL || + ConfigDefault.ChartJSURL != defaultChartJSURL || + ConfigDefault.CustomHead != defaultCustomHead { + if ConfigDefault.Refresh < minRefresh { + ConfigDefault.Refresh = minRefresh + } + // update default index with new default title/refresh + ConfigDefault.index = newIndex(viewBag{ + ConfigDefault.Title, + ConfigDefault.Refresh, + ConfigDefault.FontURL, + ConfigDefault.ChartJSURL, + ConfigDefault.CustomHead, + }) + } + + // Return default config if nothing provided + if len(config) < 1 { + return ConfigDefault + } + + // Override default config + cfg := config[0] + + // Set default values + if cfg.Title == "" { + cfg.Title = ConfigDefault.Title + } + + if cfg.Refresh == 0 { + cfg.Refresh = ConfigDefault.Refresh + } + if cfg.FontURL == "" { + cfg.FontURL = defaultFontURL + } + + if cfg.ChartJSURL == "" { + cfg.ChartJSURL = defaultChartJSURL + } + if cfg.Refresh < minRefresh { + cfg.Refresh = minRefresh + } + + if cfg.Next == nil { + cfg.Next = ConfigDefault.Next + } + + if !cfg.APIOnly { + cfg.APIOnly = ConfigDefault.APIOnly + } + + // update cfg.index with custom title/refresh + cfg.index = newIndex(viewBag{ + title: cfg.Title, + refresh: cfg.Refresh, + fontURL: cfg.FontURL, + chartJSURL: cfg.ChartJSURL, + customHead: cfg.CustomHead, + }) + + return cfg +} diff --git a/monitor/config_test.go b/monitor/config_test.go new file mode 100644 index 00000000..9a4101c9 --- /dev/null +++ b/monitor/config_test.go @@ -0,0 +1,163 @@ +package monitor + +import ( + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" +) + +func Test_Config_Default(t *testing.T) { + t.Parallel() + + t.Run("use default", func(t *testing.T) { + t.Parallel() + cfg := configDefault() + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, defaultRefresh, defaultFontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set title", func(t *testing.T) { + t.Parallel() + title := "title" + cfg := configDefault(Config{ + Title: title, + }) + + assert.Equal(t, title, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{title, defaultRefresh, defaultFontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set refresh less than default", func(t *testing.T) { + t.Parallel() + cfg := configDefault(Config{ + Refresh: 100 * time.Millisecond, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, minRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, minRefresh, defaultFontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set refresh", func(t *testing.T) { + t.Parallel() + refresh := time.Second + cfg := configDefault(Config{ + Refresh: refresh, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, refresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, refresh, defaultFontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set font url", func(t *testing.T) { + t.Parallel() + fontURL := "https://example.com" + cfg := configDefault(Config{ + FontURL: fontURL, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, fontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, defaultRefresh, fontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set chart js url", func(t *testing.T) { + t.Parallel() + chartURL := "http://example.com" + cfg := configDefault(Config{ + ChartJSURL: chartURL, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, chartURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, defaultRefresh, defaultFontURL, chartURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set custom head", func(t *testing.T) { + t.Parallel() + head := "head" + cfg := configDefault(Config{ + CustomHead: head, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, head, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, defaultRefresh, defaultFontURL, defaultChartJSURL, head}), cfg.index) + }) + + t.Run("set api only", func(t *testing.T) { + t.Parallel() + cfg := configDefault(Config{ + APIOnly: true, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, true, cfg.APIOnly) + assert.IsType(t, (func(*fiber.Ctx) bool)(nil), cfg.Next) + assert.Equal(t, newIndex(viewBag{defaultTitle, defaultRefresh, defaultFontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) + + t.Run("set next", func(t *testing.T) { + t.Parallel() + f := func(c *fiber.Ctx) bool { + return true + } + cfg := configDefault(Config{ + Next: f, + }) + + assert.Equal(t, defaultTitle, cfg.Title) + assert.Equal(t, defaultRefresh, cfg.Refresh) + assert.Equal(t, defaultFontURL, cfg.FontURL) + assert.Equal(t, defaultChartJSURL, cfg.ChartJSURL) + assert.Equal(t, defaultCustomHead, cfg.CustomHead) + assert.Equal(t, false, cfg.APIOnly) + assert.Equal(t, f(nil), cfg.Next(nil)) + assert.Equal(t, newIndex(viewBag{defaultTitle, defaultRefresh, defaultFontURL, defaultChartJSURL, defaultCustomHead}), cfg.index) + }) +} \ No newline at end of file diff --git a/monitor/go.mod b/monitor/go.mod new file mode 100644 index 00000000..8a15c043 --- /dev/null +++ b/monitor/go.mod @@ -0,0 +1,32 @@ +module github.com/gofiber/contrib/monitor + +go 1.22 + +require ( + github.com/gofiber/fiber/v3 v3.0.0-beta.3 + github.com/shirou/gopsutil/v4 v4.24.8 + github.com/stretchr/testify v1.9.0 + github.com/valyala/fasthttp v1.55.0 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/monitor/go.sum b/monitor/go.sum new file mode 100644 index 00000000..ca0474e0 --- /dev/null +++ b/monitor/go.sum @@ -0,0 +1,59 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg= +github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY= +github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co= +github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI= +github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= +github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/monitor/index.go b/monitor/index.go new file mode 100644 index 00000000..c873290c --- /dev/null +++ b/monitor/index.go @@ -0,0 +1,271 @@ +package monitor + +import ( + "strconv" + "strings" + "time" +) + +type viewBag struct { + title string + refresh time.Duration + fontURL string + chartJSURL string + customHead string +} + +// returns index with new title/refresh +func newIndex(dat viewBag) string { + timeout := dat.refresh.Milliseconds() - timeoutDiff + if timeout < timeoutDiff { + timeout = timeoutDiff + } + ts := strconv.FormatInt(timeout, 10) + replacer := strings.NewReplacer("$TITLE", dat.title, "$TIMEOUT", ts, + "$FONT_URL", dat.fontURL, "$CHART_JS_URL", dat.chartJSURL, "$CUSTOM_HEAD", dat.customHead, + ) + return replacer.Replace(indexHTML) +} + +const ( + defaultTitle = "Fiber Monitor" + + defaultRefresh = 3 * time.Second + timeoutDiff = 200 // timeout will be Refresh (in milliseconds) - timeoutDiff + minRefresh = timeoutDiff * time.Millisecond + defaultFontURL = `https://fonts.googleapis.com/css2?family=Roboto:wght@400;900&display=swap` + defaultChartJSURL = `https://cdn.jsdelivr.net/npm/chart.js@2.9/dist/Chart.bundle.min.js` + defaultCustomHead = `` + + // parametrized by $TITLE and $TIMEOUT + indexHTML = ` + + + + + + + + $TITLE + + + +
+

$TITLE

+
+
+
+
CPU Usage
+

0.00%

+
+
+ +
+
+
+
+
Memory Usage
+

0.00 MB

+
+
+ +
+
+
+
+
Response Time
+

0ms

+
+
+ +
+
+
+
+
Open Connections
+

0

+
+
+ +
+
+
+
+ + + +` +) diff --git a/monitor/monitor.go b/monitor/monitor.go new file mode 100644 index 00000000..0803afeb --- /dev/null +++ b/monitor/monitor.go @@ -0,0 +1,137 @@ +package monitor + +import ( + "os" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" + "github.com/shirou/gopsutil/v4/process" +) + +type stats struct { + PID statsPID `json:"pid"` + OS statsOS `json:"os"` +} + +type statsPID struct { + CPU float64 `json:"cpu"` + RAM uint64 `json:"ram"` + Conns int `json:"conns"` +} + +type statsOS struct { + CPU float64 `json:"cpu"` + RAM uint64 `json:"ram"` + TotalRAM uint64 `json:"total_ram"` + LoadAvg float64 `json:"load_avg"` + Conns int `json:"conns"` +} + +var ( + monitPIDCPU atomic.Value + monitPIDRAM atomic.Value + monitPIDConns atomic.Value + + monitOSCPU atomic.Value + monitOSRAM atomic.Value + monitOSTotalRAM atomic.Value + monitOSLoadAvg atomic.Value + monitOSConns atomic.Value +) + +var ( + mutex sync.RWMutex + once sync.Once + data = &stats{} +) + +// New creates a new middleware handler +func New(config ...Config) fiber.Handler { + // Set default config + cfg := configDefault(config...) + + // Start routine to update statistics + once.Do(func() { + p, _ := process.NewProcess(int32(os.Getpid())) //nolint:errcheck // TODO: Handle error + numcpu := runtime.NumCPU() + updateStatistics(p, numcpu) + + go func() { + for { + time.Sleep(cfg.Refresh) + + updateStatistics(p, numcpu) + } + }() + }) + + // Return new handler + //nolint:errcheck // Ignore the type-assertion errors + return func(c fiber.Ctx) error { + // Don't execute middleware if Next returns true + if cfg.Next != nil && cfg.Next(&c) { + return c.Next() + } + + if c.Method() != fiber.MethodGet { + return fiber.ErrMethodNotAllowed + } + if c.Get(fiber.HeaderAccept) == fiber.MIMEApplicationJSON || cfg.APIOnly { + mutex.Lock() + data.PID.CPU, _ = monitPIDCPU.Load().(float64) + data.PID.RAM, _ = monitPIDRAM.Load().(uint64) + data.PID.Conns, _ = monitPIDConns.Load().(int) + + data.OS.CPU, _ = monitOSCPU.Load().(float64) + data.OS.RAM, _ = monitOSRAM.Load().(uint64) + data.OS.TotalRAM, _ = monitOSTotalRAM.Load().(uint64) + data.OS.LoadAvg, _ = monitOSLoadAvg.Load().(float64) + data.OS.Conns, _ = monitOSConns.Load().(int) + mutex.Unlock() + return c.Status(fiber.StatusOK).JSON(data) + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).SendString(cfg.index) + } +} + +func updateStatistics(p *process.Process, numcpu int) { + pidCPU, err := p.Percent(0) + if err == nil { + monitPIDCPU.Store(pidCPU / float64(numcpu)) + } + + if osCPU, err := cpu.Percent(0, false); err == nil && len(osCPU) > 0 { + monitOSCPU.Store(osCPU[0]) + } + + if pidRAM, err := p.MemoryInfo(); err == nil && pidRAM != nil { + monitPIDRAM.Store(pidRAM.RSS) + } + + if osRAM, err := mem.VirtualMemory(); err == nil && osRAM != nil { + monitOSRAM.Store(osRAM.Used) + monitOSTotalRAM.Store(osRAM.Total) + } + + if loadAvg, err := load.Avg(); err == nil && loadAvg != nil { + monitOSLoadAvg.Store(loadAvg.Load1) + } + + pidConns, err := net.ConnectionsPid("tcp", p.Pid) + if err == nil { + monitPIDConns.Store(len(pidConns)) + } + + osConns, err := net.Connections("tcp") + if err == nil { + monitOSConns.Store(len(osConns)) + } +} diff --git a/monitor/monitor_test.go b/monitor/monitor_test.go new file mode 100644 index 00000000..97a5d8fa --- /dev/null +++ b/monitor/monitor_test.go @@ -0,0 +1,198 @@ +package monitor + +import ( + "bytes" + "fmt" + "io" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + + "github.com/valyala/fasthttp" +) + +func Test_Monitor_405(t *testing.T) { + t.Parallel() + + app := fiber.New() + + app.Use("/", New()) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/", nil)) + assert.Equal(t, nil, err) + assert.Equal(t, 405, resp.StatusCode) +} + +func Test_Monitor_Html(t *testing.T) { + t.Parallel() + + app := fiber.New() + + // defaults + app.Get("/", New()) + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil)) + + assert.Equal(t, nil, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, fiber.MIMETextHTMLCharsetUTF8, + resp.Header.Get(fiber.HeaderContentType)) + buf, err := io.ReadAll(resp.Body) + assert.Equal(t, nil, err) + assert.Equal(t, true, bytes.Contains(buf, []byte(""+defaultTitle+""))) + timeoutLine := fmt.Sprintf("setTimeout(fetchJSON, %d)", + defaultRefresh.Milliseconds()-timeoutDiff) + assert.Equal(t, true, bytes.Contains(buf, []byte(timeoutLine))) + + // custom config + conf := Config{Title: "New " + defaultTitle, Refresh: defaultRefresh + time.Second} + app.Get("/custom", New(conf)) + resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/custom", nil)) + + assert.Equal(t, nil, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, fiber.MIMETextHTMLCharsetUTF8, + resp.Header.Get(fiber.HeaderContentType)) + buf, err = io.ReadAll(resp.Body) + assert.Equal(t, nil, err) + assert.Equal(t, true, bytes.Contains(buf, []byte(""+conf.Title+""))) + timeoutLine = fmt.Sprintf("setTimeout(fetchJSON, %d)", + conf.Refresh.Milliseconds()-timeoutDiff) + assert.Equal(t, true, bytes.Contains(buf, []byte(timeoutLine))) +} + +func Test_Monitor_Html_CustomCodes(t *testing.T) { + t.Parallel() + + app := fiber.New() + + // defaults + app.Get("/", New()) + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil)) + + assert.Equal(t, nil, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, fiber.MIMETextHTMLCharsetUTF8, + resp.Header.Get(fiber.HeaderContentType)) + buf, err := io.ReadAll(resp.Body) + assert.Equal(t, nil, err) + assert.Equal(t, true, bytes.Contains(buf, []byte(""+defaultTitle+""))) + timeoutLine := fmt.Sprintf("setTimeout(fetchJSON, %d)", + defaultRefresh.Milliseconds()-timeoutDiff) + assert.Equal(t, true, bytes.Contains(buf, []byte(timeoutLine))) + + // custom config + conf := Config{ + Title: "New " + defaultTitle, + Refresh: defaultRefresh + time.Second, + ChartJSURL: "https://cdnjs.com/libraries/Chart.js", + FontURL: "/public/my-font.css", + CustomHead: ``, + } + app.Get("/custom", New(conf)) + resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/custom", nil)) + + assert.Equal(t, nil, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, fiber.MIMETextHTMLCharsetUTF8, + resp.Header.Get(fiber.HeaderContentType)) + buf, err = io.ReadAll(resp.Body) + assert.Equal(t, nil, err) + assert.Equal(t, true, bytes.Contains(buf, []byte(""+conf.Title+""))) + assert.Equal(t, true, bytes.Contains(buf, []byte("https://cdnjs.com/libraries/Chart.js"))) + assert.Equal(t, true, bytes.Contains(buf, []byte("/public/my-font.css"))) + assert.Equal(t, true, bytes.Contains(buf, []byte(conf.CustomHead))) + + timeoutLine = fmt.Sprintf("setTimeout(fetchJSON, %d)", + conf.Refresh.Milliseconds()-timeoutDiff) + assert.Equal(t, true, bytes.Contains(buf, []byte(timeoutLine))) +} + +// go test -run Test_Monitor_JSON -race +func Test_Monitor_JSON(t *testing.T) { + t.Parallel() + + app := fiber.New() + + app.Get("/", New()) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Set(fiber.HeaderAccept, fiber.MIMEApplicationJSON) + resp, err := app.Test(req) + assert.Equal(t, nil, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, fiber.MIMEApplicationJSON, resp.Header.Get(fiber.HeaderContentType)) + + b, err := io.ReadAll(resp.Body) + assert.Equal(t, nil, err) + assert.Equal(t, true, bytes.Contains(b, []byte("pid"))) + assert.Equal(t, true, bytes.Contains(b, []byte("os"))) +} + +// go test -v -run=^$ -bench=Benchmark_Monitor -benchmem -count=4 +func Benchmark_Monitor(b *testing.B) { + app := fiber.New() + + app.Get("/", New()) + + h := app.Handler() + + fctx := &fasthttp.RequestCtx{} + fctx.Request.Header.SetMethod(fiber.MethodGet) + fctx.Request.SetRequestURI("/") + fctx.Request.Header.Set(fiber.HeaderAccept, fiber.MIMEApplicationJSON) + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + h(fctx) + } + }) + + assert.Equal(b, 200, fctx.Response.Header.StatusCode()) + assert.Equal(b, + fiber.MIMEApplicationJSON, + string(fctx.Response.Header.Peek(fiber.HeaderContentType))) +} + +// go test -run Test_Monitor_Next +func Test_Monitor_Next(t *testing.T) { + t.Parallel() + + app := fiber.New() + + app.Use("/", New(Config{ + Next: func(_ *fiber.Ctx) bool { + return true + }, + })) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/", nil)) + assert.Equal(t, nil, err) + assert.Equal(t, 404, resp.StatusCode) +} + +// go test -run Test_Monitor_APIOnly -race +func Test_Monitor_APIOnly(t *testing.T) { + app := fiber.New() + + app.Get("/", New(Config{ + APIOnly: true, + })) + + req := httptest.NewRequest(fiber.MethodGet, "/", nil) + req.Header.Set(fiber.HeaderAccept, fiber.MIMEApplicationJSON) + resp, err := app.Test(req) + assert.Equal(t, nil, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, fiber.MIMEApplicationJSON, resp.Header.Get(fiber.HeaderContentType)) + + b, err := io.ReadAll(resp.Body) + assert.Equal(t, nil, err) + assert.Equal(t, true, bytes.Contains(b, []byte("pid"))) + assert.Equal(t, true, bytes.Contains(b, []byte("os"))) +} \ No newline at end of file