chore: minor code enhancements

This commit is contained in:
Ferdinand Mütsch 2020-10-09 21:37:16 +02:00
parent 4d22756b8a
commit 21b822de42
17 changed files with 72 additions and 55 deletions

9
config/templates.go Normal file
View File

@ -0,0 +1,9 @@
package config
const (
IndexTemplate = "index.tpl.html"
ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html"
SettingsTemplate = "settings.tpl.html"
SummaryTemplate = "summary.tpl.html"
)

View File

@ -64,7 +64,6 @@ func main() {
log.Println(err) log.Println(err)
log.Fatal("could not connect to database") log.Fatal("could not connect to database")
} }
// TODO: Graceful shutdown
defer db.Close() defer db.Close()
// Migrate database schema // Migrate database schema

View File

@ -1,4 +1,5 @@
package models package models
func init() { func init() {
// nothing no init here, yet
} }

View File

@ -3,7 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"github.com/gorilla/schema" "github.com/gorilla/schema"
config2 "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -14,7 +14,7 @@ import (
) )
type IndexHandler struct { type IndexHandler struct {
config *config2.Config config *conf.Config
userSrvc *services.UserService userSrvc *services.UserService
keyValueSrvc *services.KeyValueService keyValueSrvc *services.KeyValueService
} }
@ -24,7 +24,7 @@ var signupDecoder = schema.NewDecoder()
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler { func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
return &IndexHandler{ return &IndexHandler{
config: config2.Get(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
keyValueSrvc: keyValueService, keyValueSrvc: keyValueService,
} }
@ -44,7 +44,7 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
return return
} }
templates["index.tpl.html"].Execute(w, nil) templates[conf.IndexTemplate].Execute(w, nil)
} }
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
@ -57,7 +57,7 @@ func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
text = data.Value text = data.Value
} }
templates["imprint.tpl.html"].Execute(w, &struct { templates[conf.ImprintTemplate].Execute(w, &struct {
HtmlText string HtmlText string
}{HtmlText: text}) }{HtmlText: text})
} }
@ -133,11 +133,11 @@ func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
return return
} }
if handleAlerts(w, r, "signup.tpl.html") { if handleAlerts(w, r, conf.SignupTemplate) {
return return
} }
templates["signup.tpl.html"].Execute(w, nil) templates[conf.SignupTemplate].Execute(w, nil)
} }
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
@ -152,26 +152,26 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
var signup models.Signup var signup models.Signup
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest) respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return return
} }
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil { if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest) respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return return
} }
if !signup.IsValid() { if !signup.IsValid() {
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest) respondAlert(w, "invalid parameters", "", conf.SignupTemplate, http.StatusBadRequest)
return return
} }
_, created, err := h.userSrvc.CreateOrGet(&signup) _, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil { if err != nil {
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError) respondAlert(w, "failed to create new user", "", conf.SignupTemplate, http.StatusInternalServerError)
return return
} }
if !created { if !created {
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict) respondAlert(w, "user already existing", "", conf.SignupTemplate, http.StatusConflict)
return return
} }

View File

@ -59,7 +59,7 @@ func loadTemplates() {
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) { func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status) w.WriteHeader(status)
if tplName == "" { if tplName == "" {
tplName = "index.tpl.html" tplName = config.IndexTemplate
} }
templates[tplName].Execute(w, struct { templates[tplName].Execute(w, struct {
Error string Error string

View File

@ -3,7 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"github.com/gorilla/schema" "github.com/gorilla/schema"
config2 "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@ -12,7 +12,7 @@ import (
) )
type SettingsHandler struct { type SettingsHandler struct {
config *config2.Config config *conf.Config
userSrvc *services.UserService userSrvc *services.UserService
} }
@ -20,7 +20,7 @@ var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService) *SettingsHandler { func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
return &SettingsHandler{ return &SettingsHandler{
config: config2.Get(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
} }
} }
@ -36,10 +36,10 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
} }
// TODO: when alerts are present, other data will not be passed to the template // TODO: when alerts are present, other data will not be passed to the template
if handleAlerts(w, r, "settings.tpl.html") { if handleAlerts(w, r, conf.SettingsTemplate) {
return return
} }
templates["settings.tpl.html"].Execute(w, data) templates[conf.SettingsTemplate].Execute(w, data)
} }
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
@ -51,32 +51,32 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
var credentials models.CredentialsReset var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest) respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
return return
} }
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil { if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest) respondAlert(w, "missing parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
return return
} }
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.Security.PasswordSalt) { if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.Security.PasswordSalt) {
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized) respondAlert(w, "invalid credentials", "", conf.SettingsTemplate, http.StatusUnauthorized)
return return
} }
if !credentials.IsValid() { if !credentials.IsValid() {
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest) respondAlert(w, "invalid parameters", "", conf.SettingsTemplate, http.StatusBadRequest)
return return
} }
user.Password = credentials.PasswordNew user.Password = credentials.PasswordNew
if err := utils.HashPassword(user, h.config.Security.PasswordSalt); err != nil { if err := utils.HashPassword(user, h.config.Security.PasswordSalt); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }
@ -86,7 +86,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
} }
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login) encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil { if err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }
@ -110,7 +110,7 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil { if _, err := h.userSrvc.ResetApiKey(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }
@ -126,7 +126,7 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil { if _, err := h.userSrvc.ToggleBadges(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) respondAlert(w, "internal server error", "", conf.SettingsTemplate, http.StatusInternalServerError)
return return
} }

View File

@ -1,7 +1,7 @@
package routes package routes
import ( import (
config2 "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@ -10,13 +10,13 @@ import (
type SummaryHandler struct { type SummaryHandler struct {
summarySrvc *services.SummaryService summarySrvc *services.SummaryService
config *config2.Config config *conf.Config
} }
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler { func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
return &SummaryHandler{ return &SummaryHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: config2.Get(), config: conf.Get(),
} }
} }
@ -44,13 +44,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
summary, err, status := h.loadUserSummary(r) summary, err, status := h.loadUserSummary(r)
if err != nil { if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", status) respondAlert(w, err.Error(), "", conf.SummaryTemplate, status)
return return
} }
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if user == nil { if user == nil {
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized) respondAlert(w, "unauthorized", "", conf.SummaryTemplate, http.StatusUnauthorized)
return return
} }
@ -60,7 +60,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
ApiKey: user.ApiKey, ApiKey: user.ApiKey,
} }
templates["summary.tpl.html"].Execute(w, vm) templates[conf.SummaryTemplate].Execute(w, vm)
} }
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) { func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {

View File

@ -3,7 +3,7 @@ import random
import string import string
import sys import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List from typing import List, NoneType
import requests import requests
@ -36,7 +36,7 @@ class Heartbeat:
self.is_write: bool = is_write self.is_write: bool = is_write
self.branch: str = branch self.branch: str = branch
self.type: str = type self.type: str = type
self.category: str = None self.category: str | NoneType = None
def generate_data(n: int) -> List[Heartbeat]: def generate_data(n: int) -> List[Heartbeat]:
@ -45,7 +45,7 @@ def generate_data(n: int) -> List[Heartbeat]:
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(5)] projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(5)]
languages: List[str] = list(LANGUAGES.keys()) languages: List[str] = list(LANGUAGES.keys())
for i in range(n): for _ in range(n):
p: str = random.choice(projects) p: str = random.choice(projects)
l: str = random.choice(languages) l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8)) f: str = randomword(random.randint(2, 8))
@ -77,7 +77,7 @@ def post_data_sync(data: List[Heartbeat], url: str):
def randomword(length: int) -> str: def randomword(length: int) -> str:
letters = string.ascii_lowercase letters = string.ascii_lowercase
return ''.join(random.choice(letters) for i in range(length)) return ''.join(random.choice(letters) for _ in range(length))
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -12,7 +12,7 @@ import (
) )
const ( const (
aggregateIntervalDays int = 1 // TODO: Make configurable aggregateIntervalDays int = 1
) )
type AggregationService struct { type AggregationService struct {

View File

@ -37,6 +37,7 @@ type Interval struct {
End time.Time End time.Time
} }
// TODO: simplify!
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) { func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
var existingSummaries []*models.Summary var existingSummaries []*models.Summary
var cacheKey string var cacheKey string

View File

@ -242,8 +242,8 @@ function equalizeHeights() {
}) })
} }
function getTotal(data) { function getTotal(items) {
let total = data.reduce((acc, d) => acc + d.total, 0) let total = items.reduce((acc, d) => acc + d.total, 0)
document.getElementById('total-span').innerText = total.toString().toHHMMSS() document.getElementById('total-span').innerText = total.toString().toHHMMSS()
} }

View File

@ -1 +1 @@
1.12.2 1.12.3

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
@ -86,7 +87,7 @@
<div class="flex flex-col mb-4"> <div class="flex flex-col mb-4">
<div class="flex justify-between my-2"> <div class="flex justify-between my-2">
<div> <div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/> <img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today" alt="Shields.io badge"/>
</div> </div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;"> <span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
@ -94,7 +95,7 @@
</div> </div>
<div class="flex justify-between my-2"> <div class="flex justify-between my-2">
<div> <div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/> <img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d" alt="Shields.io badge"/>
</div> </div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;"> <span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
@ -104,7 +105,7 @@
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p> <p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
{{ else }} {{ else }}
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p> <p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800" rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
<div class="flex justify-around mt-4"> <div class="flex justify-around mt-4">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span> <span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges"> <button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
@ -19,9 +20,11 @@
<p class="text-sm text-gray-300"> <p class="text-sm text-gray-300">
💡 In order to use Wakapi, you need to create an account. 💡 In order to use Wakapi, you need to create an account.
After successful signup, you still need to set up the <a href="https://wakatime.com" target="_blank" After successful signup, you still need to set up the <a href="https://wakatime.com" target="_blank"
rel="noopener noreferrer"
class="border-b border-green-700">WakaTime</a> class="border-b border-green-700">WakaTime</a>
client tools. client tools.
Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank" Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank"
rel="noopener noreferrer"
class="border-b border-green-700">this readme section</a> for instructions. class="border-b border-green-700">this readme section</a> for instructions.
You will be able to view you <strong>API Key</strong> once you log in. You will be able to view you <strong>API Key</strong> once you log in.
</p> </p>

View File

@ -1,4 +1,5 @@
<html> <!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
@ -69,7 +70,7 @@
</div> </div>
<canvas id="chart-projects"></canvas> <canvas id="chart-projects"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/> <img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span> <span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
@ -82,7 +83,7 @@
</div> </div>
<canvas id="chart-os"></canvas> <canvas id="chart-os"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/> <img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span> <span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
@ -95,7 +96,7 @@
</div> </div>
<canvas id="chart-language"></canvas> <canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/> <img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span> <span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
@ -108,7 +109,7 @@
</div> </div>
<canvas id="chart-editor"></canvas> <canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/> <img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span> <span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
@ -121,7 +122,7 @@
</div> </div>
<canvas id="chart-machine"></canvas> <canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/> <img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
<span class="text-sm mt-4">No data available ...</span> <span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>