diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1097d..6dbfcf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- New: Implemented a new responsive user interface that is friendly for desktop, mobile, and tablet devices. The design is based on the adminkit.io template, and all pages are refactored to ensure a seamless user experience across different platforms. Probably some new bugs are introduced ¯\\_(ツ)_/¯* - Update: rename cmd/web directory to app, just internal change. - Update: Code optimization. No UI change. Includes: - Migrated all javascript code to a js files instead of keeping them in gohtml templates, plus some html code optimization. This significantly reduces amouunt of typo and errors. diff --git a/README.md b/README.md index 8773067..f320c3b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Web-logbook -This is a simple free EASA-style logbook application written in golang. +This is a simple, free and opensource EASA-style logbook application written in golang. You can clone the repo and compile the binaries yourself, or just download the latest ones for your operating system from the [releases](https://github.com/vsimakhin/web-logbook/releases). @@ -12,6 +12,7 @@ You also can easily export all flight records into EASA style pdf format, print ## [Unreleased] +- New: Implemented a new responsive user interface that is friendly for desktop, mobile, and tablet devices. The design is based on the adminkit.io template, and all pages are refactored to ensure a seamless user experience across different platforms. Probably some new bugs are introduced ¯\\_(ツ)_/¯* - Update: rename cmd/web directory to app, just internal change. - Update: Code optimization. No UI change. Includes: - Migrated all javascript code to a js files instead of keeping them in gohtml templates, plus some html code optimization. This significantly reduces amouunt of typo and errors. @@ -50,11 +51,9 @@ The full changelog is [here](https://github.com/vsimakhin/web-logbook/blob/main/ 1. Run: * Windows: * Double-click on the `web-logbook.exe` file. It will show you some warning about how unsafe it can be (need to solve it later), but just run it. - * Linux: + * Linux/MacOS: * Open a terminal and navigate to the directory * Run `./web-logbook` - * MacOS: - * *I still didn't test it for MacOS, so in theory, should be as same as for Linux, but... who knows ¯\\_(ツ)_/¯* 4. Open your browser, type http://localhost:4000 and the application is ready to use * *(first run)* Go to the [Settings](http://localhost:4000/settings) page, `Airports` tab and click on the `Update Airport DB` button 6. To close the application, use `Ctrl+C` in the terminal window or just close it @@ -86,87 +85,90 @@ $ ./web-logbook -h # Supported operating systems -Since it's written in golang it can run on any system if you compile the sources. For now, on the [Release](https://github.com/vsimakhin/web-logbook/releases/latest) page, there are 3 binaries for Linux, MacOS and Windows, all of them are amd64. +Since it's written in Golang, it can run on any system after compiling the sources. Currently, on the [Release](https://github.com/vsimakhin/web-logbook/releases/latest) page, there are binaries available for Linux, MacOS, and Windows. -There is an application [Web Logbook Mobile Ionic](https://github.com/vsimakhin/web-logbook-mobile-ionic) for Android (and later I hope will be for IOS), which can sync with the main application. # Interface -Currently, there are implemented several modules in the logbook app: -* Logbook itself - * Flight records +* Logbook + * Flight records with date filter and global search through all data + * Quick export to PDF (A4, A5) and CSV/XLS +* Flight records + * Flight data * Attachments for the flight records * Automatic night-time calculation * Map drawing and distance calculation for the flight record -* Settings - * Signature and owner name - * Signature pad to automatically include signatures to the PDF exports - * Enable/Disable authentication (in case you need to expose the app to the public internet) - * Aircraft groups/classes - * List global airport database - * Your own custom airfields or heliports - * Some interface settings -* Export - * Export to EASA PDF format (A4 and A5) - * Additional export formats (XLSX, CSV) - * Adjustable settings for each export format -* Import - * CSV support - * Automatic WebLogbook profile load -* Map - * Filters for routes and airports - * Filters for the aircraft * Licensing & Certification + * List of licenses, certificates and endorsements * Document attachments and preview * Expiration time tracking +* Map + * Map of the flights + * Date filters + * Routes and airports filters + * Aircraft filters * Statistics * Totals * By Year * By Aircraft - * By Aircraft group/class, defined in settings + * By Aircraft group/class + * Limits (EASA flight time limitations) +* Export + * Export to EASA PDF format (A4 and A5) + * PDF export formats with custom title pages (for example, include your CV automatically) + * Additional export formats (XLSX, CSV) + * Adjustable settings for each export format +* Import + * CSV support + * Automatic WebLogbook profile load +* Settings + * Owner name, license and address, signature for the PDF exports + * Signature pad to automatically include signatures to the PDF exports + * Enable/Disable authentication (in case you need to expose the app to the public internet) + * Aircraft groups/classes + * Global airport database + * Your own custom airfields or heliports + * Some interface settings ## Logbook +![EASA Logbook](./readme-assets/logbook.png) -![Main logbook page](https://github.com/vsimakhin/web-logbook-assets/raw/main/logbook-main.png) +## Flight record +![Flight record](./readme-assets/flight-record.png) -## Export +## Licensing & Certification +![Licensing & Certification](./readme-assets/licensing-record.png) -![Export](https://github.com/vsimakhin/web-logbook-assets/raw/main/export.png) +## Map +![Map of the flights](./readme-assets/map-example.png) + +## Stats example +![Flight stats example](./readme-assets/stats-example.png) + +## Export +![Export](./readme-assets/export-page.png) ### A4 -![Export to PDF](https://github.com/vsimakhin/web-logbook-assets/raw/main/logbook-export.png) +![Export to PDF](./readme-assets/logbook-export.png) ### A5 -![Export to PDF](https://github.com/vsimakhin/web-logbook-assets/raw/main/export-a5-a.png) -![Export to PDF](https://github.com/vsimakhin/web-logbook-assets/raw/main/export-a5-b.png) +![Export to PDF](./readme-assets/export-a5-a.png) +![Export to PDF](./readme-assets/export-a5-b.png) So in real life the logbook could look like -![Pilot logbook](https://github.com/vsimakhin/web-logbook-assets/raw/main/logbook_irl.jpg) - -## Flight record +![Pilot logbook](./readme-assets/logbook_irl.jpg) -![Flight record](https://github.com/vsimakhin/web-logbook-assets/raw/main/flight-record-example.png) - -### Attachments -![Flight record attachments](https://github.com/vsimakhin/web-logbook-assets/raw/main/flight-record-example-attachments.png) +## Import +![Import](./readme-assets/import.png) ## Settings +![Settings](./readme-assets/settings-general.png) -![Settings](https://github.com/vsimakhin/web-logbook-assets/raw/main/settings.png) - -![Settings-Airports](https://github.com/vsimakhin/web-logbook-assets/raw/main/settings-airports.png) - -## Stats - -![Flight stats](https://github.com/vsimakhin/web-logbook-assets/raw/main/stats.png) - -![Map](https://github.com/vsimakhin/web-logbook-assets/raw/main/stats-map.png) - -## Licensing & Certifications - -![Licensing](https://github.com/vsimakhin/web-logbook-assets/raw/main/licensing.png) +## Dark mode +![Dark mode](./readme-assets/dark-mode.png) -![Licensing record](https://github.com/vsimakhin/web-logbook-assets/raw/main/licensing-record.png) +## Mobile friendly +![Mobile friendly](./readme-assets/mobile-friendly.png) # Airports Databases @@ -219,6 +221,7 @@ In case you'd like to add some other features to the logbook or you found a bug, # Used libraries +* Adminkit.io https://adminkit.io * Bootstrap https://getbootstrap.com/ * Datatables https://datatables.net/ * Openlayers https://openlayers.org/ diff --git a/app/handlers_auth.go b/app/handlers_auth.go index ba7a514..a90741e 100644 --- a/app/handlers_auth.go +++ b/app/handlers_auth.go @@ -90,7 +90,7 @@ func (app *application) HandlerLoginPost(w http.ResponseWriter, r *http.Request) time.Now().Unix() + int64(loginAttempts[ip].failedAttempts*10), } - app.errorLog.Println(err) + app.warningLog.Println(err) response.OK = false response.Message = err.Error() diff --git a/app/handlers_auth_test.go b/app/handlers_auth_test.go index 43884da..a391d0d 100644 --- a/app/handlers_auth_test.go +++ b/app/handlers_auth_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io" "net/http" "net/http/httptest" "testing" @@ -24,18 +23,13 @@ func TestAuth(t *testing.T) { // auth enabled app.isAuthEnabled = true resp, _ := http.Get(fmt.Sprintf("%s/", srv.URL)) - responseBody, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, string(responseBody), ``) } @@ -49,10 +43,8 @@ func TestHandlerLogin(t *testing.T) { defer srv.Close() resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, APILogin)) - responseBody, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, string(responseBody), `This requested URL was not found on this server

`) } diff --git a/app/handlers_export.go b/app/handlers_export.go index 919d5ec..42b00b8 100644 --- a/app/handlers_export.go +++ b/app/handlers_export.go @@ -17,11 +17,23 @@ const exportA5 = "A5" const exportCSV = "csv" const exportXLS = "xls" -// HandlerExport is a handler for /export page -func (app *application) HandlerExport(w http.ResponseWriter, r *http.Request) { +// HandlerExportPDFA4Page is a handler for /export-pdf-a4 page +func (app *application) HandlerExportPDFA4Page(w http.ResponseWriter, r *http.Request) { + if err := app.renderTemplate(w, r, "export-pdf-a4", &templateData{}); err != nil { + app.errorLog.Println(err) + } +} + +// HandlerExportPDFA5Page is a handler for /export-pdf-a5 page +func (app *application) HandlerExportPDFA5Page(w http.ResponseWriter, r *http.Request) { + if err := app.renderTemplate(w, r, "export-pdf-a5", &templateData{}); err != nil { + app.errorLog.Println(err) + } +} - partials := []string{"export-a4", "export-a5", "export-xls", "export-csv"} - if err := app.renderTemplate(w, r, "export", &templateData{}, partials...); err != nil { +// HandlerExportPDFA5Page is a handler for /export-pdf-a5 page +func (app *application) HandlerExportCSVXLSPage(w http.ResponseWriter, r *http.Request) { + if err := app.renderTemplate(w, r, "export-csv-xls", &templateData{}); err != nil { app.errorLog.Println(err) } } @@ -181,8 +193,7 @@ func (app *application) HandlerExportRestoreDefaults(w http.ResponseWriter, r *h response.Message = err.Error() } else { response.OK = true - response.Message = "Export settings have been updated" - response.RedirectURL = fmt.Sprintf("%s?param=%s", APIExport, param) + response.Message = "Export settings have been restored. Refresh the page to see the changes." } app.writeJSON(w, http.StatusOK, response) diff --git a/app/handlers_export_test.go b/app/handlers_export_test.go index ff1bcd7..a182ade 100644 --- a/app/handlers_export_test.go +++ b/app/handlers_export_test.go @@ -12,27 +12,6 @@ import ( "github.com/vsimakhin/web-logbook/internal/models" ) -func TestHandlerExport(t *testing.T) { - - app, mock := initTestApplication() - - models.InitMock(mock, "GetSettings") - - srv := httptest.NewServer(app.routes()) - defer srv.Close() - - resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, APIExport)) - responseBody, _ := io.ReadAll(resp.Body) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - assert.Contains(t, string(responseBody), `PDF A4`) - assert.Contains(t, string(responseBody), `PDF A5`) - assert.Contains(t, string(responseBody), `XLS`) - assert.Contains(t, string(responseBody), `CSV`) - -} - func TestHandlerExportLogbook(t *testing.T) { app, mock := initTestApplication() diff --git a/app/handlers_flightrecord_test.go b/app/handlers_flightrecord_test.go index 62518fa..3c7b5c8 100644 --- a/app/handlers_flightrecord_test.go +++ b/app/handlers_flightrecord_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io" "net/http" "net/http/httptest" "strings" @@ -24,13 +23,9 @@ func TestHandlerFlightRecordNew(t *testing.T) { defer srv.Close() resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, APILogbookNew)) - responseBody, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, string(responseBody), ``) - assert.Contains(t, string(responseBody), ``) - } func TestHandlerFlightRecordByID(t *testing.T) { @@ -46,11 +41,7 @@ func TestHandlerFlightRecordByID(t *testing.T) { defer srv.Close() resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, strings.ReplaceAll(APILogbookUUID, "{uuid}", "uuid"))) - responseBody, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, string(responseBody), ``) - assert.Contains(t, string(responseBody), ``) - } diff --git a/app/handlers_licensing_test.go b/app/handlers_licensing_test.go index a9377a3..70c4e55 100644 --- a/app/handlers_licensing_test.go +++ b/app/handlers_licensing_test.go @@ -28,7 +28,6 @@ func TestHandlerLicensing(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) // need to have datatables css and js - assert.Contains(t, string(responseBody), ``) assert.Contains(t, string(responseBody), ``) } diff --git a/app/handlers_logbook_test.go b/app/handlers_logbook_test.go index 098355b..38e7a37 100644 --- a/app/handlers_logbook_test.go +++ b/app/handlers_logbook_test.go @@ -27,7 +27,6 @@ func TestHandlerLogbook(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) // need to have datatables css and js - assert.Contains(t, string(responseBody), ``) assert.Contains(t, string(responseBody), ``) } diff --git a/app/handlers_map_test.go b/app/handlers_map_test.go index 4d1c383..5000495 100644 --- a/app/handlers_map_test.go +++ b/app/handlers_map_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io" "net/http" "net/http/httptest" "testing" @@ -24,13 +23,9 @@ func TestHandlerMap(t *testing.T) { defer srv.Close() resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, APIMap)) - responseBody, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Contains(t, string(responseBody), ``) - assert.Contains(t, string(responseBody), ``) - } func TestHandlerMapGetData(t *testing.T) { diff --git a/app/handlers_settings.go b/app/handlers_settings.go index aecceb9..5d93ab0 100644 --- a/app/handlers_settings.go +++ b/app/handlers_settings.go @@ -9,7 +9,13 @@ import ( // HandlerSettings is a handler for Settings page func (app *application) HandlerSettings(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + if err := app.renderTemplate(w, r, "settings", &templateData{Data: data}); err != nil { + app.errorLog.Println(err) + } +} +func (app *application) HandlerSettingsAirportDB(w http.ResponseWriter, r *http.Request) { records, err := app.db.GetAirportCount() if err != nil { app.errorLog.Println(err) @@ -19,11 +25,8 @@ func (app *application) HandlerSettings(w http.ResponseWriter, r *http.Request) data := make(map[string]interface{}) data["records"] = records - data["urls"] = app.getServerUrls() - - partials := []string{"settings-general", "settings-airports", "settings-misc", "settings-sync"} - if err := app.renderTemplate(w, r, "settings", &templateData{Data: data}, partials...); err != nil { + if err := app.renderTemplate(w, r, "settings-airportdb", &templateData{Data: data}); err != nil { app.errorLog.Println(err) } } @@ -61,7 +64,7 @@ func (app *application) HandlerSettingsSave(w http.ResponseWriter, r *http.Reque response.Message = err.Error() } else { response.OK = true - response.Message = "Settings have been updated" + response.Message = "Settings have been updated. You may refresh the page to see the changes." } if app.isAuthEnabled != settings.AuthEnabled && settings.AuthEnabled { @@ -73,6 +76,42 @@ func (app *application) HandlerSettingsSave(w http.ResponseWriter, r *http.Reque app.writeJSON(w, http.StatusOK, response) } +// HandlerSettingsAirportDBSave serves the POST request for airportdb settings update +func (app *application) HandlerSettingsAirportDBSave(w http.ResponseWriter, r *http.Request) { + + cursettings, err := app.db.GetSettings() + if err != nil { + app.errorLog.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var settings models.Settings + var response models.JSONResponse + + err = json.NewDecoder(r.Body).Decode(&settings) + if err != nil { + app.errorLog.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cursettings.AirportDBSource = settings.AirportDBSource + cursettings.NoICAOFilter = settings.NoICAOFilter + + err = app.db.UpdateSettings(cursettings) + if err != nil { + app.errorLog.Println(err) + response.OK = false + response.Message = err.Error() + } else { + response.OK = true + response.Message = "Settings have been updated" + } + + app.writeJSON(w, http.StatusOK, response) +} + // HandlerSettingsAircraftClasses is a handler for aircraft groups/classes func (app *application) HandlerSettingsAircraftClasses(w http.ResponseWriter, r *http.Request) { diff --git a/app/handlers_settings_test.go b/app/handlers_settings_test.go index 3cb11d6..f683d06 100644 --- a/app/handlers_settings_test.go +++ b/app/handlers_settings_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io" "net/http" "net/http/httptest" "testing" @@ -11,29 +10,6 @@ import ( "github.com/vsimakhin/web-logbook/internal/models" ) -func TestHandlerSettings(t *testing.T) { - - app, mock := initTestApplication() - - models.InitMock(mock, "GetSettings") - models.InitMock(mock, "GetAirportCount") - - srv := httptest.NewServer(app.routes()) - defer srv.Close() - - resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, APISettings)) - responseBody, _ := io.ReadAll(resp.Body) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - assert.Contains(t, string(responseBody), `Owner Name`) - assert.Contains(t, string(responseBody), `Signature`) - assert.Contains(t, string(responseBody), `Airport database`) - - assert.Contains(t, string(responseBody), `General`) - assert.Contains(t, string(responseBody), `Misc`) -} - func TestHandlerSettingsAircraftClasses(t *testing.T) { app, mock := initTestApplication() diff --git a/app/handlers_stats.go b/app/handlers_stats.go index 6d75b38..6cd213b 100644 --- a/app/handlers_stats.go +++ b/app/handlers_stats.go @@ -251,8 +251,6 @@ func (app *application) HandlerStatsTotals(w http.ResponseWriter, r *http.Reques // HandlerStatsLimits is a handler for the Flight Time Limitations table source func (app *application) HandlerStatsLimits(w http.ResponseWriter, r *http.Request) { - var tableData models.TableData - totals, err := app.getTotalStats("", "") if err != nil { app.errorLog.Println(err) @@ -260,21 +258,41 @@ func (app *application) HandlerStatsLimits(w http.ResponseWriter, r *http.Reques return } - // let's form our custom table - tableData.Data = append(tableData.Data, - []string{ - totals["Last28"].Time.Total, - totals["Last90"].Time.Total, - totals["Last12M"].Time.Total, - totals["Year"].Time.Total, - }) + detailed, err := app.getDetailedLimitsStats() + if err != nil { + app.errorLog.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - app.writeJSON(w, http.StatusOK, tableData) + data := map[string]map[string]interface{}{ + "totals": { + "last28": totals["Last28"].Time.Total, + "last90": totals["Last90"].Time.Total, + "last12m": totals["Last12M"].Time.Total, + "last1y": totals["Year"].Time.Total, + }, + "detailed": { + "last28": detailed["Last28"], + "last90": detailed["Last90"], + "last12m": detailed["Last12m"], + "last1y": detailed["Last1y"], + }, + } + + app.writeJSON(w, http.StatusOK, data) } -// HandlerStats is a handler for Stats page -func (app *application) HandlerStats(w http.ResponseWriter, r *http.Request) { +// HandlerStats is a handler for Stats Totals page +func (app *application) HandlerStatsTotalsPage(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + if err := app.renderTemplate(w, r, "stats-totals", &templateData{Data: data}); err != nil { + app.errorLog.Println(err) + } +} +// HandlerStatsTotalsByYearPage is a handler for Stats Totals by Year page +func (app *application) HandlerStatsTotalsByYearPage(w http.ResponseWriter, r *http.Request) { data := make(map[string]interface{}) totalsByYear, err := app.db.GetTotalsByYear() @@ -285,10 +303,43 @@ func (app *application) HandlerStats(w http.ResponseWriter, r *http.Request) { } data["totalsByYear"] = totalsByYear + if err := app.renderTemplate(w, r, "stats-totals-year", &templateData{Data: data}); err != nil { + app.errorLog.Println(err) + } +} + +// HandlerStatsTotalsByTypePage is a handler for Stats Totals by Type page +func (app *application) HandlerStatsTotalsByTypePage(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + if err := app.renderTemplate(w, r, "stats-totals-type", &templateData{Data: data}); err != nil { + app.errorLog.Println(err) + } +} + +// HandlerStatsTotalsByClassPage is a handler for Stats Totals by Class page +func (app *application) HandlerStatsTotalsByClassPage(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + if err := app.renderTemplate(w, r, "stats-totals-class", &templateData{Data: data}); err != nil { + app.errorLog.Println(err) + } +} + +// HandlerStatsLimitsPage is a handler for Stats Limits page +func (app *application) HandlerStatsLimitsPage(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + totals, err := app.getTotalStats("", "") + if err != nil { + app.errorLog.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - partials := []string{"stats-totals", "stats-totals-year", "stats-totals-type", "stats-totals-class"} + data["last28"] = totals["Last28"].Time.Total + data["last90"] = totals["Last90"].Time.Total + data["last12m"] = totals["Last12M"].Time.Total + data["last1y"] = totals["Year"].Time.Total - if err := app.renderTemplate(w, r, "stats", &templateData{Data: data}, partials...); err != nil { + if err := app.renderTemplate(w, r, "stats-limits", &templateData{Data: data}); err != nil { app.errorLog.Println(err) } } diff --git a/app/handlers_stats_test.go b/app/handlers_stats_test.go index b5ab6f1..504df65 100644 --- a/app/handlers_stats_test.go +++ b/app/handlers_stats_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io" "net/http" "net/http/httptest" "testing" @@ -11,51 +10,6 @@ import ( "github.com/vsimakhin/web-logbook/internal/models" ) -func TestHandlerStats(t *testing.T) { - - app, mock := initTestApplication() - - // first tab - // totals - models.InitMock(mock, "GetTotals") - // last 28 days - models.InitMock(mock, "GetTotals") - // last 90 days - models.InitMock(mock, "GetTotals") - // this months - models.InitMock(mock, "GetTotals") - // this year - models.InitMock(mock, "GetTotals") - - // second tab - models.InitMock(mock, "GetSettings") - models.InitMock(mock, "GetTotalsClassType") - - // third tab - models.InitMock(mock, "GetTotalsClassType") - - // fourth tab - models.InitMock(mock, "GetTotalsYear") - - srv := httptest.NewServer(app.routes()) - defer srv.Close() - - resp, _ := http.Get(fmt.Sprintf("%s%s", srv.URL, APIStats)) - responseBody, _ := io.ReadAll(resp.Body) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - // first tab with totals - assert.Contains(t, string(responseBody), `