BREAKING: Use yaml for bookmarks
This commit is contained in:
parent
39d91f4d14
commit
c9820499cf
20 changed files with 311 additions and 305 deletions
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
|
@ -1,6 +1,6 @@
|
|||
pipeline {
|
||||
environment {
|
||||
VERSION = "v2.0.0"
|
||||
VERSION = "v2.0.1"
|
||||
PROJECT_NAME = JOB_NAME.split('/')
|
||||
IMAGE_NAME = "unjxde/${PROJECT_NAME[0]}"
|
||||
IMAGE = ''
|
||||
|
|
73
README.md
73
README.md
|
@ -32,52 +32,45 @@ Please refer to the available options as shown in the docker-compose example.
|
|||
|
||||
### Example of the bookmarks.json
|
||||
|
||||
All Bookmarks are read from a file called `bookmarks.json` located inside the `./storage` folder.
|
||||
All Bookmarks are read from a file called `config.yaml` located inside the `./storage` folder.
|
||||
The application will create a default file at startup and will automatically look for changes inside the file.
|
||||
Changes are printed in stdout when running with `LOG_LEVEL=trace`.
|
||||
|
||||
You can specify an icon of a bookmark either by using a link or by using the name of the file located inside the `./storage/icons` folder that is mounted via the docker compose file.
|
||||
The name and related link can be provided as well.
|
||||
|
||||
**bookmarks.json example:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"CATEGORY": "First",
|
||||
"ENTRIES": [
|
||||
{
|
||||
"NAME": "Github",
|
||||
"ICON": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
|
||||
"URL": "https://github.com"
|
||||
},
|
||||
{
|
||||
"NAME": "Jenkins",
|
||||
"ICON": "jenkins.webp",
|
||||
"URL": "https://www.jenkins.io/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"CATEGORY": "",
|
||||
"ENTRIES": [
|
||||
{
|
||||
"NAME": "Github",
|
||||
"ICON": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
|
||||
"URL": "https://github.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"CATEGORY": "Third",
|
||||
"ENTRIES": [
|
||||
{
|
||||
"NAME": "Github",
|
||||
"ICON": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
|
||||
"URL": "https://github.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
**config.yaml example:**
|
||||
```yaml
|
||||
links:
|
||||
- category: "Code"
|
||||
entries:
|
||||
- name: "Github"
|
||||
url: "https://github.com"
|
||||
- category: "CI/CD"
|
||||
entries:
|
||||
- name: "Jenkins"
|
||||
url: "https://www.jenkins.io/"
|
||||
- category: "Server"
|
||||
entries:
|
||||
- name: "bwCloud"
|
||||
url: "https://portal.bw-cloud.org"
|
||||
|
||||
applications:
|
||||
- category: "Code"
|
||||
entries:
|
||||
- name: "Github"
|
||||
icon: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
||||
url: "https://github.com"
|
||||
- category: ""
|
||||
entries:
|
||||
- name: "Jenkins"
|
||||
icon: "https://www.jenkins.io/images/logos/jenkins/Jenkins-stop-the-war.svg"
|
||||
url: "https://www.jenkins.io/"
|
||||
- category: "Server"
|
||||
entries:
|
||||
- name: "bwCloud"
|
||||
icon: "https://portal.bw-cloud.org/static/dashboard/img/logo-splash.svg"
|
||||
url: "https://portal.bw-cloud.org"
|
||||
```
|
||||
|
||||
### Available environment variables with default values
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package bookmarks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
folderCreate "github.com/unjx-de/go-folder"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
@ -13,60 +13,60 @@ import (
|
|||
const StorageDir = "storage/"
|
||||
const IconsDir = StorageDir + "icons/"
|
||||
const bookmarksFolder = "bookmarks/"
|
||||
const bookmarksFile = "bookmarks.json"
|
||||
const bookmarksFile = "config.yaml"
|
||||
|
||||
func NewBookmarkService(logging *zap.SugaredLogger) *Bookmarks {
|
||||
b := Bookmarks{log: logging}
|
||||
func NewBookmarkService(logging *zap.SugaredLogger) *Config {
|
||||
b := Config{log: logging}
|
||||
b.createFolderStructure()
|
||||
b.parseBookmarks()
|
||||
go b.watchBookmarks()
|
||||
return &b
|
||||
}
|
||||
|
||||
func (b *Bookmarks) createFolderStructure() {
|
||||
func (c *Config) createFolderStructure() {
|
||||
folders := []string{StorageDir, IconsDir}
|
||||
err := folderCreate.CreateFolders(folders, 0755)
|
||||
if err != nil {
|
||||
b.log.Fatal(err)
|
||||
c.log.Fatal(err)
|
||||
}
|
||||
b.log.Debug("folders created")
|
||||
c.log.Debugw("folders created", "folders", folders)
|
||||
}
|
||||
|
||||
func (b *Bookmarks) copyDefaultBookmarks() {
|
||||
func (c *Config) copyDefaultBookmarks() {
|
||||
source, _ := os.Open(bookmarksFolder + bookmarksFile)
|
||||
defer source.Close()
|
||||
destination, err := os.Create(StorageDir + bookmarksFile)
|
||||
if err != nil {
|
||||
b.log.Error(err)
|
||||
c.log.Error(err)
|
||||
}
|
||||
defer destination.Close()
|
||||
_, err = io.Copy(destination, source)
|
||||
if err != nil {
|
||||
b.log.Error(err)
|
||||
c.log.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bookmarks) readBookmarksFile() []byte {
|
||||
jsonFile, err := os.Open(StorageDir + bookmarksFile)
|
||||
func (c *Config) readBookmarksFile() []byte {
|
||||
file, err := os.Open(StorageDir + bookmarksFile)
|
||||
if err != nil {
|
||||
b.copyDefaultBookmarks()
|
||||
jsonFile, err = os.Open(StorageDir + bookmarksFile)
|
||||
c.copyDefaultBookmarks()
|
||||
file, err = os.Open(StorageDir + bookmarksFile)
|
||||
if err != nil {
|
||||
b.log.Error(err)
|
||||
c.log.Error(err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
defer jsonFile.Close()
|
||||
byteValue, err := io.ReadAll(jsonFile)
|
||||
defer file.Close()
|
||||
byteValue, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
b.log.Error(err)
|
||||
c.log.Error(err)
|
||||
return nil
|
||||
}
|
||||
return byteValue
|
||||
}
|
||||
|
||||
func (b *Bookmarks) replaceIconString() {
|
||||
for _, v := range b.Categories {
|
||||
func (c *Config) replaceIconString() {
|
||||
for _, v := range c.Parsed.Applications {
|
||||
for i, bookmark := range v.Entries {
|
||||
if !strings.Contains(bookmark.Icon, "http") {
|
||||
v.Entries[i].Icon = "/" + IconsDir + bookmark.Icon
|
||||
|
@ -75,20 +75,20 @@ func (b *Bookmarks) replaceIconString() {
|
|||
}
|
||||
}
|
||||
|
||||
func (b *Bookmarks) parseBookmarks() {
|
||||
byteValue := b.readBookmarksFile()
|
||||
err := json.Unmarshal(byteValue, &b.Categories)
|
||||
func (c *Config) parseBookmarks() {
|
||||
byteValue := c.readBookmarksFile()
|
||||
err := yaml.Unmarshal(byteValue, &c.Parsed)
|
||||
if err != nil {
|
||||
b.log.Error(err)
|
||||
c.log.Error(err)
|
||||
return
|
||||
}
|
||||
b.replaceIconString()
|
||||
c.replaceIconString()
|
||||
}
|
||||
|
||||
func (b *Bookmarks) watchBookmarks() {
|
||||
func (c *Config) watchBookmarks() {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
b.log.Error(err)
|
||||
c.log.Error(err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
done := make(chan bool)
|
||||
|
@ -96,23 +96,17 @@ func (b *Bookmarks) watchBookmarks() {
|
|||
go func() {
|
||||
for {
|
||||
select {
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b.log.Error(err)
|
||||
case _, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b.parseBookmarks()
|
||||
b.log.Debug("bookmarks changed", "categories", len(b.Categories))
|
||||
case err, _ := <-watcher.Errors:
|
||||
c.log.Error(err)
|
||||
case _, _ = <-watcher.Events:
|
||||
c.parseBookmarks()
|
||||
c.log.Debug("bookmarks changed", "applications", len(c.Parsed.Applications), "links", len(c.Parsed.Links))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := watcher.Add(StorageDir + bookmarksFile); err != nil {
|
||||
b.log.Fatal()
|
||||
c.log.Fatal()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
[
|
||||
{
|
||||
"CATEGORY": "First",
|
||||
"ENTRIES": [
|
||||
{
|
||||
"NAME": "Github",
|
||||
"ICON": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
|
||||
"URL": "https://github.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"CATEGORY": "",
|
||||
"ENTRIES": [
|
||||
{
|
||||
"NAME": "Github",
|
||||
"ICON": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
|
||||
"URL": "https://github.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"CATEGORY": "Third",
|
||||
"ENTRIES": [
|
||||
{
|
||||
"NAME": "Github",
|
||||
"ICON": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
|
||||
"URL": "https://github.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
30
bookmarks/config.yaml
Normal file
30
bookmarks/config.yaml
Normal file
|
@ -0,0 +1,30 @@
|
|||
links:
|
||||
- category: "Code"
|
||||
entries:
|
||||
- name: "Github"
|
||||
url: "https://github.com"
|
||||
- category: "CI/CD"
|
||||
entries:
|
||||
- name: "Jenkins"
|
||||
url: "https://www.jenkins.io/"
|
||||
- category: "Server"
|
||||
entries:
|
||||
- name: "bwCloud"
|
||||
url: "https://portal.bw-cloud.org"
|
||||
|
||||
applications:
|
||||
- category: "Code"
|
||||
entries:
|
||||
- name: "Github"
|
||||
icon: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
||||
url: "https://github.com"
|
||||
- category: ""
|
||||
entries:
|
||||
- name: "Jenkins"
|
||||
icon: "https://www.jenkins.io/images/logos/jenkins/Jenkins-stop-the-war.svg"
|
||||
url: "https://www.jenkins.io/"
|
||||
- category: "Server"
|
||||
entries:
|
||||
- name: "bwCloud"
|
||||
icon: "https://portal.bw-cloud.org/static/dashboard/img/logo-splash.svg"
|
||||
url: "https://portal.bw-cloud.org"
|
|
@ -2,18 +2,23 @@ package bookmarks
|
|||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
type Bookmarks struct {
|
||||
log *zap.SugaredLogger
|
||||
Categories []Category
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Category string `json:"category"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Url string `json:"url"`
|
||||
type Config struct {
|
||||
log *zap.SugaredLogger
|
||||
Parsed struct {
|
||||
Links []struct {
|
||||
Category string
|
||||
Entries []struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
}
|
||||
Applications []struct {
|
||||
Category string
|
||||
Entries []struct {
|
||||
Name string
|
||||
Icon string
|
||||
URL string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
1
go.mod
1
go.mod
|
@ -11,6 +11,7 @@ require (
|
|||
github.com/shirou/gopsutil/v3 v3.22.11
|
||||
github.com/unjx-de/go-folder v1.0.7
|
||||
go.uber.org/zap v1.24.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
12
go.sum
12
go.sum
|
@ -22,7 +22,6 @@ github.com/labstack/echo/v4 v4.9.1 h1:GliPYSpzGKlyOhqIbG8nmHBo3i1saKWFOgh41AN3b+
|
|||
github.com/labstack/echo/v4 v4.9.1/go.mod h1:Pop5HLc+xoc4qhTZ1ip6C0RtP7Z+4VzRLWZZFKqbbjo=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
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/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
|
||||
|
@ -36,7 +35,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig=
|
||||
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
|
@ -45,7 +43,6 @@ github.com/shirou/gopsutil/v3 v3.22.11/go.mod h1:xl0EeL4vXJ+hQMAGN8B9VFpxukEMA0X
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
|
@ -59,24 +56,18 @@ github.com/unjx-de/go-folder v1.0.7 h1:OVKvqjcVB0ASidVshYndRtkmlqS1h6MIhSr0vqX3Q
|
|||
github.com/unjx-de/go-folder v1.0.7/go.mod h1:sbcRrRgLE49QI6CZqGBMdneRuNOOhoRU1gx9DYlyD2g=
|
||||
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/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
|
||||
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
|
||||
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
||||
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||
|
@ -93,11 +84,10 @@ golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
|||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
|
||||
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
4
main.go
4
main.go
|
@ -27,7 +27,7 @@ type goDash struct {
|
|||
|
||||
type info struct {
|
||||
weather *weather.Weather
|
||||
bookmarks *bookmarks.Bookmarks
|
||||
bookmarks *bookmarks.Config
|
||||
system *system.System
|
||||
}
|
||||
|
||||
|
@ -63,6 +63,8 @@ func main() {
|
|||
panic(err)
|
||||
}
|
||||
|
||||
g.router.Debug = true
|
||||
|
||||
g.setupLogger()
|
||||
defer func(logger *zap.SugaredLogger) {
|
||||
_ = logger.Sync()
|
||||
|
|
|
@ -13,10 +13,10 @@ var (
|
|||
|
||||
func (g *goDash) index(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "index.gohtml", map[string]interface{}{
|
||||
"Title": g.config.Title,
|
||||
"Weather": g.info.weather.CurrentWeather,
|
||||
"Categories": g.info.bookmarks.Categories,
|
||||
"System": g.info.system.CurrentSystem,
|
||||
"Title": g.config.Title,
|
||||
"Weather": g.info.weather.CurrentWeather,
|
||||
"Parsed": g.info.bookmarks.Parsed,
|
||||
"System": g.info.system,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
@apply bg-primary-content h-1 rounded-full mt-1;
|
||||
}
|
||||
.system-icon {
|
||||
@apply h-8 w-8 shrink-0 mr-3 opacity-90 text-secondary;
|
||||
@apply h-8 w-8 shrink-0 mr-3 opacity-90;
|
||||
}
|
||||
.extra-icon {
|
||||
@apply h-3 w-3 shrink-0 mr-2 text-primary;
|
||||
|
@ -18,7 +18,16 @@
|
|||
.extra-sun-icon {
|
||||
@apply h-4 w-4 shrink-0 -mb-1 mr-2 text-primary;
|
||||
}
|
||||
.extra-system-info {
|
||||
.extra-info {
|
||||
@apply text-xs truncate text-secondary;
|
||||
}
|
||||
.grid-apps {
|
||||
@apply grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4
|
||||
}
|
||||
.hover-effect {
|
||||
@apply no-underline md:hover:underline underline-offset-2 decoration-primary text-sm text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-50 transition-all ease-linear duration-150
|
||||
}
|
||||
.heading{
|
||||
@apply text-lg text-secondary select-none truncate underline decoration-primary underline-offset-4
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,10 +19,10 @@ func staticCpu() CPU {
|
|||
return p
|
||||
}
|
||||
|
||||
func (s *System) liveCpu() {
|
||||
func (c *Config) liveCpu() {
|
||||
p, err := cpu.Percent(0, false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.CurrentSystem.Live.CPU = math.RoundToEven(p[0])
|
||||
c.System.Live.CPU = math.RoundToEven(p[0])
|
||||
}
|
||||
|
|
|
@ -19,11 +19,11 @@ func staticDisk() Disk {
|
|||
return result
|
||||
}
|
||||
|
||||
func (s *System) liveDisk() {
|
||||
func (c *Config) liveDisk() {
|
||||
d, err := disk.Usage("/")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.CurrentSystem.Live.Disk.Value = readableSize(d.Used)
|
||||
s.CurrentSystem.Live.Disk.Percentage = math.RoundToEven(percent.PercentOfFloat(float64(d.Used), float64(d.Total)))
|
||||
c.System.Live.Disk.Value = readableSize(d.Used)
|
||||
c.System.Live.Disk.Percentage = math.RoundToEven(percent.PercentOfFloat(float64(d.Used), float64(d.Total)))
|
||||
}
|
||||
|
|
|
@ -21,11 +21,11 @@ func staticRam() Ram {
|
|||
return result
|
||||
}
|
||||
|
||||
func (s *System) liveRam() {
|
||||
func (c *Config) liveRam() {
|
||||
r, err := mem.VirtualMemory()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.CurrentSystem.Live.Ram.Value = readableSize(r.Used)
|
||||
s.CurrentSystem.Live.Ram.Percentage = math.RoundToEven(percent.PercentOfFloat(float64(r.Used), float64(r.Total)))
|
||||
c.System.Live.Ram.Value = readableSize(r.Used)
|
||||
c.System.Live.Ram.Percentage = math.RoundToEven(percent.PercentOfFloat(float64(r.Used), float64(r.Total)))
|
||||
}
|
||||
|
|
|
@ -7,30 +7,30 @@ import (
|
|||
)
|
||||
|
||||
func NewSystemService(enabled bool, logging *zap.SugaredLogger, hub *hub.Hub) *System {
|
||||
var s System
|
||||
var s Config
|
||||
if enabled {
|
||||
s = System{log: logging, hub: hub}
|
||||
s = Config{log: logging, hub: hub}
|
||||
s.Initialize()
|
||||
}
|
||||
return &s
|
||||
return &s.System
|
||||
}
|
||||
|
||||
func (s *System) UpdateLiveInformation() {
|
||||
func (c *Config) UpdateLiveInformation() {
|
||||
for {
|
||||
s.liveCpu()
|
||||
s.liveRam()
|
||||
s.liveDisk()
|
||||
s.uptime()
|
||||
s.hub.LiveInformationCh <- hub.Message{WsType: hub.System, Message: s.CurrentSystem.Live}
|
||||
c.liveCpu()
|
||||
c.liveRam()
|
||||
c.liveDisk()
|
||||
c.uptime()
|
||||
c.hub.LiveInformationCh <- hub.Message{WsType: hub.System, Message: c.System.Live}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *System) Initialize() {
|
||||
s.CurrentSystem.Static.Host = staticHost()
|
||||
s.CurrentSystem.Static.CPU = staticCpu()
|
||||
s.CurrentSystem.Static.Ram = staticRam()
|
||||
s.CurrentSystem.Static.Disk = staticDisk()
|
||||
go s.UpdateLiveInformation()
|
||||
s.log.Debugw("system updated", "cpu", s.CurrentSystem.Static.CPU.Name, "arch", s.CurrentSystem.Static.Host.Architecture)
|
||||
func (c *Config) Initialize() {
|
||||
c.System.Static.Host = staticHost()
|
||||
c.System.Static.CPU = staticCpu()
|
||||
c.System.Static.Ram = staticRam()
|
||||
c.System.Static.Disk = staticDisk()
|
||||
go c.UpdateLiveInformation()
|
||||
c.log.Debugw("system updated", "cpu", c.System.Static.CPU.Name, "arch", c.System.Static.Host.Architecture)
|
||||
}
|
||||
|
|
|
@ -5,13 +5,13 @@ import (
|
|||
"godash/hub"
|
||||
)
|
||||
|
||||
type System struct {
|
||||
hub *hub.Hub
|
||||
log *zap.SugaredLogger
|
||||
CurrentSystem CurrentSystem
|
||||
type Config struct {
|
||||
hub *hub.Hub
|
||||
log *zap.SugaredLogger
|
||||
System System
|
||||
}
|
||||
|
||||
type CurrentSystem struct {
|
||||
type System struct {
|
||||
Live LiveInformation `json:"live"`
|
||||
Static StaticInformation `json:"static"`
|
||||
}
|
||||
|
|
|
@ -4,14 +4,14 @@ import (
|
|||
"github.com/shirou/gopsutil/v3/host"
|
||||
)
|
||||
|
||||
func (s *System) uptime() {
|
||||
func (c *Config) uptime() {
|
||||
i, err := host.Info()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.CurrentSystem.Live.Uptime.Days = i.Uptime / 84600
|
||||
s.CurrentSystem.Live.Uptime.Hours = uint16((i.Uptime % 86400) / 3600)
|
||||
s.CurrentSystem.Live.Uptime.Minutes = uint16(((i.Uptime % 86400) % 3600) / 60)
|
||||
s.CurrentSystem.Live.Uptime.Seconds = uint16(((i.Uptime % 86400) % 3600) % 60)
|
||||
s.CurrentSystem.Live.Uptime.Percentage = float32((s.CurrentSystem.Live.Uptime.Minutes*100)+s.CurrentSystem.Live.Uptime.Seconds) / 60
|
||||
c.System.Live.Uptime.Days = i.Uptime / 84600
|
||||
c.System.Live.Uptime.Hours = uint16((i.Uptime % 86400) / 3600)
|
||||
c.System.Live.Uptime.Minutes = uint16(((i.Uptime % 86400) % 3600) / 60)
|
||||
c.System.Live.Uptime.Seconds = uint16(((i.Uptime % 86400) % 3600) % 60)
|
||||
c.System.Live.Uptime.Percentage = float32((c.System.Live.Uptime.Minutes*100)+c.System.Live.Uptime.Seconds) / 60
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ module.exports = {
|
|||
},
|
||||
dark: {
|
||||
...require("daisyui/src/colors/themes")["[data-theme=halloween]"],
|
||||
secondary: "#b9b9b9",
|
||||
secondary: "#a0a0a0",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<div class="mx-auto container px-5 lg:px-8 my-3 md:my-10 lg:my-14 xl:my-20">{{ template "content" . }}</div>
|
||||
<div class="mx-auto container px-5 lg:px-8 my-3 md:my-6 lg:my-10 xl:my-14">{{ template "content" . }}</div>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
|
|
@ -6,138 +6,152 @@
|
|||
|
||||
{{ define "content" }}
|
||||
|
||||
{{ if .Weather.Icon }}
|
||||
{{ template "weatherIcons" . }}
|
||||
<div class="grid gap-10">
|
||||
{{ if .Weather.Icon }}
|
||||
{{ template "weatherIcons" . }}
|
||||
|
||||
|
||||
<div class="flex items-center mb-6 md:mb-10 select-none">
|
||||
<svg class="h-12 w-12 shrink-0 mr-4 md:w-14 md:h-14">
|
||||
<use id="weatherIcon" class="text-secondary" xlink:href="#{{ .Weather.Icon }}"></use>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-4xl md:text-4xl">
|
||||
<span id="weatherTemp">{{ .Weather.Temp }}</span> {{ .Weather.Units }}
|
||||
</div>
|
||||
<div class="flex items-center gap-5 text-xs">
|
||||
<div class="flex items-center">
|
||||
<svg class="extra-icon">
|
||||
<use xlink:href="#quote"></use>
|
||||
</svg>
|
||||
<div id="weatherDescription">{{ .Weather.Description }}</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="extra-icon">
|
||||
<use xlink:href="#humidity"></use>
|
||||
</svg>
|
||||
<div id="weatherHumidity">{{ .Weather.Humidity }}%</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center">
|
||||
<svg class="extra-sun-icon">
|
||||
<use xlink:href="#sunrise"></use>
|
||||
</svg>
|
||||
<div id="weatherSunrise">{{ .Weather.Sunrise }}</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center">
|
||||
<svg class="extra-sun-icon">
|
||||
<use xlink:href="#sunset"></use>
|
||||
</svg>
|
||||
<div id="weatherSunset">{{ .Weather.Sunset }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .System.Static.Host.Architecture }}
|
||||
{{ template "systemIcons" . }}
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-6 md:mb-10 select-none">
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#cpu"></use>
|
||||
<div class="flex items-center select-none">
|
||||
<svg class="h-12 w-12 shrink-0 mr-4 md:w-14 md:h-14">
|
||||
<use id="weatherIcon" xlink:href="#{{ .Weather.Icon }}"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-system-info">{{ .System.Static.CPU.Threads }}</div>
|
||||
<div class="truncate">{{ .System.Static.CPU.Name }}</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemCpuPercentage" class="progress-bar" style="width: {{ .System.Live.CPU }}%"></div>
|
||||
<div>
|
||||
<div class="text-4xl md:text-4xl">
|
||||
<span id="weatherTemp">{{ .Weather.Temp }}</span> {{ .Weather.Units }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#ram"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-system-info">{{ .System.Static.Ram.Swap }}</div>
|
||||
<div class="truncate">
|
||||
<span id="systemRamValue">{{ .System.Live.Ram.Value }}</span> /
|
||||
{{ .System.Static.Ram.Total }}
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemRamPercentage" class="progress-bar" style="width: {{ .System.Live.Ram.Percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#disk"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-system-info">{{ .System.Static.Disk.Partitions }}</div>
|
||||
<div class="truncate">
|
||||
<span id="systemDiskValue">{{ .System.Live.Disk.Value }}</span> /
|
||||
{{ .System.Static.Disk.Total }}
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemDiskPercentage" class="progress-bar" style="width: {{ .System.Live.Disk.Percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#server"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-system-info">{{ .System.Static.Host.Architecture }}</div>
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<div class="truncate">
|
||||
<span><span id="uptimeDays">{{ .System.Live.Uptime.Days }}</span> days</span>
|
||||
<span class="countdown"><span id="uptimeHours" style="--value:{{ .System.Live.Uptime.Hours }};"></span></span> hours
|
||||
<span class="countdown"><span id="uptimeMinutes" style="--value:{{ .System.Live.Uptime.Minutes }};"></span></span> min
|
||||
<span class="countdown"><span id="uptimeSeconds" style="--value:{{ .System.Live.Uptime.Seconds }};"></span></span> sec
|
||||
<div class="flex items-center gap-5 text-xs">
|
||||
<div class="flex items-center">
|
||||
<svg class="extra-icon">
|
||||
<use xlink:href="#quote"></use>
|
||||
</svg>
|
||||
<div id="weatherDescription" class="extra-info">{{ .Weather.Description }}</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="extra-icon">
|
||||
<use xlink:href="#humidity"></use>
|
||||
</svg>
|
||||
<div id="weatherHumidity" class="extra-info">{{ .Weather.Humidity }}%</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center">
|
||||
<svg class="extra-sun-icon">
|
||||
<use xlink:href="#sunrise"></use>
|
||||
</svg>
|
||||
<div id="weatherSunrise" class="extra-info">{{ .Weather.Sunrise }}</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center">
|
||||
<svg class="extra-sun-icon">
|
||||
<use xlink:href="#sunset"></use>
|
||||
</svg>
|
||||
<div id="weatherSunset" class="extra-info">{{ .Weather.Sunset }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemUptimePercentage" class="progress-bar" style="width: {{ .System.Live.Uptime.Percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
|
||||
<div class="grid gap-4">
|
||||
{{ range .Categories }}
|
||||
<div class="grid gap-1">
|
||||
{{ if .Category }}
|
||||
<div class="text-lg text-secondary select-none truncate">{{ .Category }}</div>
|
||||
{{ end }}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{{ range .Entries }}
|
||||
<a
|
||||
href="{{ .Url }}"
|
||||
class="bookmark-link flex items-center no-underline md:hover:underline underline-offset-2 decoration-primary text-sm text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-50 transition-all ease-linear duration-150"
|
||||
>
|
||||
<div class="img rounded-md w-8 h-8 bg-cover bg-center opacity-90" style="background-image: url({{ .Icon }})"></div>
|
||||
<div class="uppercase truncate ml-2">{{ .Name }}</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .System.Static.Host.Architecture }}
|
||||
{{ template "systemIcons" . }}
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 select-none">
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#cpu"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-info">{{ .System.Static.CPU.Threads }}</div>
|
||||
<div class="truncate">{{ .System.Static.CPU.Name }}</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemCpuPercentage" class="progress-bar" style="width: {{ .System.Live.CPU }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#ram"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-info">{{ .System.Static.Ram.Swap }}</div>
|
||||
<div class="truncate">
|
||||
<span id="systemRamValue">{{ .System.Live.Ram.Value }}</span> /
|
||||
{{ .System.Static.Ram.Total }}
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemRamPercentage" class="progress-bar" style="width: {{ .System.Live.Ram.Percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#disk"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-info">{{ .System.Static.Disk.Partitions }}</div>
|
||||
<div class="truncate">
|
||||
<span id="systemDiskValue">{{ .System.Live.Disk.Value }}</span> /
|
||||
{{ .System.Static.Disk.Total }}
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemDiskPercentage" class="progress-bar" style="width: {{ .System.Live.Disk.Percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<svg class="system-icon">
|
||||
<use xlink:href="#server"></use>
|
||||
</svg>
|
||||
<div class="w-full truncate">
|
||||
<div class="extra-info">{{ .System.Static.Host.Architecture }}</div>
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<div class="truncate">
|
||||
<span><span id="uptimeDays">{{ .System.Live.Uptime.Days }}</span> days</span>
|
||||
<span class="countdown"><span id="uptimeHours" style="--value:{{ .System.Live.Uptime.Hours }};"></span></span> hours
|
||||
<span class="countdown"><span id="uptimeMinutes" style="--value:{{ .System.Live.Uptime.Minutes }};"></span></span> min
|
||||
<span class="countdown"><span id="uptimeSeconds" style="--value:{{ .System.Live.Uptime.Seconds }};"></span></span> sec
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="systemUptimePercentage" class="progress-bar" style="width: {{ .System.Live.Uptime.Percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
|
||||
<div class="grid gap-4">
|
||||
{{ range .Parsed.Applications }}
|
||||
<div class="grid gap-1">
|
||||
{{ if .Category }}
|
||||
<div class="heading">{{ .Category }}</div>
|
||||
{{ end }}
|
||||
<div class="grid-apps">
|
||||
{{ range .Entries }}
|
||||
<a href="{{ .URL }}" class="bookmark-link flex items-center hover-effect">
|
||||
<div class="img rounded-md w-8 h-8 bg-cover bg-center opacity-90" style="background-image: url({{ .Icon }})"></div>
|
||||
<div class="uppercase truncate ml-2">{{ .Name }}</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="grid-apps">
|
||||
{{ range .Parsed.Links }}
|
||||
<div class="flex flex-col gap-1">
|
||||
{{ if .Category }}
|
||||
<div class="heading">{{ .Category }}</div>
|
||||
{{ end }}
|
||||
{{ range .Entries }}
|
||||
<a href="{{ .URL }}" class="hover-effect">
|
||||
<div class="uppercase truncate">{{ .Name }}</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
Loading…
Add table
Reference in a new issue