Merge latest changes

This commit is contained in:
Svilen Markov 2024-12-03 13:59:20 +00:00
commit 0651a2886e
131 changed files with 4277 additions and 3827 deletions

View File

@ -10,4 +10,4 @@ WORKDIR /app
COPY --from=builder /app/glance . COPY --from=builder /app/glance .
EXPOSE 8080/tcp EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"] ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

View File

@ -5,4 +5,4 @@ COPY glance .
EXPOSE 8080/tcp EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"] ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

View File

@ -52,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a
``` ```
#### Docker #### Docker
<!-- TODO: update -->
> [!IMPORTANT] > [!IMPORTANT]
> >
> Make sure you have a valid `glance.yml` file in the same directory before running the container. > Make sure you have a valid `glance.yml` file in the same directory before running the container.

View File

@ -35,6 +35,7 @@
- [Docker](#docker) - [Docker](#docker)
## Intro ## Intro
<!-- TODO: update -->
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error. Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
## Preconfigured page ## Preconfigured page
@ -114,6 +115,8 @@ This will give you a page that looks like the following:
Configure the widgets, add more of them, add extra pages, etc. Make it your own! Configure the widgets, add more of them, add extra pages, etc. Make it your own!
<!-- TODO: update - add information about top level document key -->
## Server ## Server
Server configuration is done through a top level `server` property. Example: Server configuration is done through a top level `server` property. Example:
@ -1341,6 +1344,7 @@ Preview:
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| service | string | no | pihole | | service | string | no | pihole |
| allow-insecure | bool | no | false |
| url | string | yes | | | url | string | yes | |
| username | string | when service is `adguard` | | | username | string | when service is `adguard` | |
| password | string | when service is `adguard` | | | password | string | when service is `adguard` | |
@ -1350,6 +1354,9 @@ Preview:
##### `service` ##### `service`
Either `adguard` or `pihole`. Either `adguard` or `pihole`.
##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service.
##### `url` ##### `url`
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
@ -1597,15 +1604,25 @@ Example:
```yaml ```yaml
- type: calendar - type: calendar
start-sunday: false
``` ```
Preview: Preview:
![](images/calendar-widget-preview.png) ![](images/calendar-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| start-sunday | boolean | no | false |
##### `start-sunday`
Whether calendar weeks start on Sunday or Monday.
> [!NOTE] > [!NOTE]
> >
> There is currently no customizability available for the calendar. Extra features will be added in the future. > There is currently little customizability available for the calendar. Extra features will be added in the future.
### Markets ### Markets
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.

6
go.mod
View File

@ -3,9 +3,10 @@ module github.com/glanceapp/glance
go 1.23.1 go 1.23.1
require ( require (
github.com/fsnotify/fsnotify v1.8.0
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.18.0 golang.org/x/text v0.20.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -18,5 +19,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.27.0 // indirect
) )

12
go.sum
View File

@ -5,6 +5,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@ -40,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -52,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -61,8 +65,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@ -1,56 +0,0 @@
package assets
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log/slog"
"strconv"
"time"
)
//go:embed static
var _publicFS embed.FS
//go:embed templates
var _templateFS embed.FS
var PublicFS, _ = fs.Sub(_publicFS, "static")
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
func getFSHash(files fs.FS) string {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err == nil {
return hex.EncodeToString(hash.Sum(nil))[:10]
}
slog.Warn("Could not compute assets cache", "err", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
var PublicFSHash = getFSHash(PublicFS)

View File

@ -1,113 +0,0 @@
package assets
import (
"fmt"
"html/template"
"math"
"strconv"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var (
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
PageContentTemplate = compileTemplate("content.html")
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
MonitorCompactTemplate = compileTemplate("monitor-compact.html", "widget-base.html")
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html")
CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html")
DockerTemplate = compileTemplate("docker.html", "widget-base.html")
)
var GlobalTemplateFunctions = template.FuncMap{
"relativeTime": relativeTimeSince,
"formatViewerCount": formatViewerCount,
"formatNumber": intl.Sprint,
"absInt": func(i int) int {
return int(math.Abs(float64(i)))
},
"formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price)
},
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
},
}
func compileTemplate(primary string, dependencies ...string) *template.Template {
t, err := template.New(primary).
Funcs(GlobalTemplateFunctions).
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
if err != nil {
panic(err)
}
return t
}
var intl = message.NewPrinter(language.English)
func formatViewerCount(count int) string {
if count < 1_000 {
return strconv.Itoa(count)
}
if count < 10_000 {
return fmt.Sprintf("%.1fk", float64(count)/1_000)
}
if count < 1_000_000 {
return fmt.Sprintf("%dk", count/1_000)
}
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
}
func relativeTimeSince(t time.Time) string {
delta := time.Since(t)
if delta < time.Minute {
return "1m"
}
if delta < time.Hour {
return fmt.Sprintf("%dm", delta/time.Minute)
}
if delta < 24*time.Hour {
return fmt.Sprintf("%dh", delta/time.Hour)
}
if delta < 30*24*time.Hour {
return fmt.Sprintf("%dd", delta/(24*time.Hour))
}
if delta < 12*30*24*time.Hour {
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
}
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
}

View File

@ -1,14 +0,0 @@
<style>
:root {
{{ if .App.Config.Theme.BackgroundColor }}
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
}
</style>

View File

@ -1,120 +0,0 @@
package feed
import (
"net/http"
"strings"
)
type adguardStatsResponse struct {
TotalQueries int `json:"num_dns_queries"`
QueriesSeries []int `json:"dns_queries"`
BlockedQueries int `json:"num_blocked_filtering"`
BlockedSeries []int `json:"blocked_filtering"`
ResponseTime float64 `json:"avg_processing_time"`
TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
}
func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.SetBasicAuth(username, password)
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request)
if err != nil {
return nil, err
}
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
ResponseTime: int(responseJson.ResponseTime * 1000),
TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
for i := 0; i < topBlockedDomainsCount; i++ {
domain := responseJson.TopBlockedDomains[i]
var firstDomain string
for k := range domain {
firstDomain = k
break
}
if firstDomain == "" {
continue
}
stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
Domain: firstDomain,
})
if stats.BlockedQueries > 0 {
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
}
}
queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries
const bars = 8
const hoursSpan = 24
const hoursPerBar int = hoursSpan / bars
if len(queriesSeries) > hoursSpan {
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
} else if len(queriesSeries) < hoursSpan {
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
}
if len(blockedSeries) > hoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
}
maxQueriesInSeries := 0
for i := 0; i < bars; i++ {
queries := 0
blocked := 0
for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j]
}
stats.Series[i] = DNSStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < bars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View File

@ -1,53 +0,0 @@
package feed
import "time"
// TODO: very inflexible, refactor to allow more customizability
// TODO: allow changing first day of week
// TODO: allow changing between showing the previous and next week and the entire month
func NewCalendar(now time.Time) *Calendar {
year, week := now.ISOWeek()
weekday := now.Weekday()
if weekday == 0 {
weekday = 7
}
currentMonthDays := daysInMonth(now.Month(), year)
var previousMonthDays int
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
previousMonthDays = daysInMonth(12, year-1)
} else {
previousMonthDays = daysInMonth(previousMonthNumber, year)
}
startDaysFrom := now.Day() - int(weekday+6)
days := make([]int, 21)
for i := 0; i < 21; i++ {
day := startDaysFrom + i
if day < 1 {
day = previousMonthDays + day
} else if day > currentMonthDays {
day = day - currentMonthDays
}
days[i] = day
}
return &Calendar{
CurrentDay: now.Day(),
CurrentWeekNumber: week,
CurrentMonthName: now.Month().String(),
CurrentYear: year,
Days: days,
}
}
func daysInMonth(m time.Month, year int) int {
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}

View File

@ -1,39 +0,0 @@
package feed
import (
"fmt"
"net/http"
)
type codebergReleaseResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
}
func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://codeberg.org/api/v1/repos/%s/releases/latest",
request.Repository,
),
nil,
)
if err != nil {
return nil, err
}
response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceCodeberg,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
}, nil
}

View File

@ -1,148 +0,0 @@
package feed
import (
"bytes"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"github.com/glanceapp/glance/internal/assets"
"github.com/tidwall/gjson"
)
func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
emptyBody := template.HTML("")
resp, err := defaultClient.Do(req)
if err != nil {
return emptyBody, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return emptyBody, err
}
body := string(bodyBytes)
if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON")
}
var templateBuffer bytes.Buffer
data := CustomAPITemplateData{
JSON: DecoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
err = tmpl.Execute(&templateBuffer, &data)
if err != nil {
return emptyBody, err
}
return template.HTML(templateBuffer.String()), nil
}
type DecoratedGJSONResult struct {
gjson.Result
}
type CustomAPITemplateData struct {
JSON DecoratedGJSONResult
Response *http.Response
}
func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult {
decoratedResults := make([]DecoratedGJSONResult, len(results))
for i, result := range results {
decoratedResults[i] = DecoratedGJSONResult{result}
}
return decoratedResults
}
func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult {
if key == "" {
return GJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
}
func (r *DecoratedGJSONResult) String(key string) string {
if key == "" {
return r.Result.String()
}
return r.Get(key).String()
}
func (r *DecoratedGJSONResult) Int(key string) int64 {
if key == "" {
return r.Result.Int()
}
return r.Get(key).Int()
}
func (r *DecoratedGJSONResult) Float(key string) float64 {
if key == "" {
return r.Result.Float()
}
return r.Get(key).Float()
}
func (r *DecoratedGJSONResult) Bool(key string) bool {
if key == "" {
return r.Result.Bool()
}
return r.Get(key).Bool()
}
var CustomAPITemplateFuncs = func() template.FuncMap {
funcs := template.FuncMap{
"toFloat": func(a int64) float64 {
return float64(a)
},
"toInt": func(a float64) int64 {
return int64(a)
},
"mathexpr": func(left float64, op string, right float64) float64 {
if right == 0 {
return 0
}
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
return left / right
default:
return 0
}
},
}
for key, value := range assets.GlobalTemplateFunctions {
funcs[key] = value
}
return funcs
}()

View File

@ -1,102 +0,0 @@
package feed
import (
"fmt"
"net/http"
"strings"
)
type dockerHubRepositoryTagsResponse struct {
Results []dockerHubRepositoryTagResponse `json:"results"`
}
type dockerHubRepositoryTagResponse struct {
Name string `json:"name"`
LastPushed string `json:"tag_last_pushed"`
}
const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
nameParts := strings.Split(request.Repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
tagParts := strings.SplitN(nameParts[1], ":", 2)
var requestURL string
if len(tagParts) == 2 {
requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
} else {
requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
}
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
var tag *dockerHubRepositoryTagResponse
if len(tagParts) == 1 {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
if len(response.Results) == 0 {
return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
}
tag = &response.Results[0]
} else {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
tag = &response
}
var repo string
var displayName string
var notesURL string
if len(tagParts) == 1 {
repo = nameParts[1]
} else {
repo = tagParts[0]
}
if nameParts[0] == "library" {
displayName = repo
notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
} else {
displayName = nameParts[0] + "/" + repo
notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
}
return &AppRelease{
Source: ReleaseSourceDockerHub,
NotesUrl: notesURL,
Name: displayName,
Version: tag.Name,
TimeReleased: parseRFC3339Time(tag.LastPushed),
}, nil
}

View File

@ -1,102 +0,0 @@
package feed
import (
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
)
type ExtensionType int
const (
ExtensionContentHTML ExtensionType = iota
ExtensionContentUnknown = iota
)
var ExtensionStringToType = map[string]ExtensionType{
"html": ExtensionContentHTML,
}
const (
ExtensionHeaderTitle = "Widget-Title"
ExtensionHeaderContentType = "Widget-Content-Type"
)
type ExtensionRequestOptions struct {
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type Extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
switch contentType {
case ExtensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("failed fetching extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
}
extension := Extension{}
if response.Header.Get(ExtensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(ExtensionHeaderTitle)
}
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
if !ok {
contentType, ok = ExtensionStringToType[options.FallbackContentType]
if !ok {
contentType = ExtensionContentUnknown
}
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View File

@ -1,48 +0,0 @@
package feed
import (
"fmt"
"net/http"
"net/url"
)
type gitlabReleaseResponseJson struct {
TagName string `json:"tag_name"`
ReleasedAt string `json:"released_at"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
}
func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
url.QueryEscape(request.Repository),
),
nil,
)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token)
}
response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceGitlab,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.Links.Self,
TimeReleased: parseRFC3339Time(response.ReleasedAt),
}, nil
}

View File

@ -1,98 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
type hackerNewsPostResponseJson struct {
Id int `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
TargetUrl string `json:"url,omitempty"`
CommentCount int `json:"descendants"`
TimePosted int64 `json:"time"`
}
func getHackerNewsPostIds(sort string) ([]int, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
if err != nil {
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
}
return response, nil
}
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
requests := make([]*http.Request, len(postIds))
for i, id := range postIds {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
requests[i] = request
}
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
job := newJob(task, requests).withWorkers(30)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(postIds))
for i := range results {
if errs[i] != nil {
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
}
posts = append(posts, ForumPost{
Title: results[i].Title,
DiscussionUrl: commentsUrl,
TargetUrl: results[i].TargetUrl,
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
CommentCount: results[i].CommentCount,
Score: results[i].Score,
TimePosted: time.Unix(results[i].TimePosted, 0),
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
if len(posts) != len(postIds) {
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
}
return posts, nil
}
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
postIds, err := getHackerNewsPostIds(sort)
if err != nil {
return nil, err
}
if len(postIds) > limit {
postIds = postIds[:limit]
}
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View File

@ -1,91 +0,0 @@
package feed
import (
"net/http"
"strings"
"time"
)
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, ForumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
return posts, nil
}
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
}
posts, err := getLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}

View File

@ -1,77 +0,0 @@
package feed
import (
"context"
"errors"
"net/http"
"time"
)
type SiteStatusRequest struct {
URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct {
Code int
TimedOut bool
ResponseTime time.Duration
Error error
}
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
requestSentAt := time.Now()
var response *http.Response
if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
status.TimedOut = true
}
status.Error = err
return status, nil
}
defer response.Body.Close()
status.Code = response.StatusCode
return status, nil
}
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job)
if err != nil {
return nil, err
}
return results, nil
}

View File

@ -1,136 +0,0 @@
package feed
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strings"
)
type piholeStatsResponse struct {
TotalQueries int `json:"dns_queries_today"`
QueriesSeries map[int64]int `json:"domains_over_time"`
BlockedQueries int `json:"ads_blocked_today"`
BlockedSeries map[int64]int `json:"ads_over_time"`
BlockedPercentage float64 `json:"ads_percentage_today"`
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
DomainsBlocked int `json:"domains_being_blocked"`
}
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
type piholeTopBlockedDomains map[string]int
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
// because of the UnmarshalJSON method getting called recursively
temp := make(map[string]int)
err := json.Unmarshal(data, &temp)
if err != nil {
*p = make(piholeTopBlockedDomains)
} else {
*p = temp
}
return nil
}
func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceURL, "/") +
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request)
if err != nil {
return nil, err
}
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
BlockedPercent: int(responseJson.BlockedPercentage),
DomainsBlocked: responseJson.DomainsBlocked,
}
if len(responseJson.TopBlockedDomains) > 0 {
domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
for domain, count := range responseJson.TopBlockedDomains {
domains = append(domains, DNSStatsBlockedDomain{
Domain: domain,
PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
})
}
sort.Slice(domains, func(a, b int) bool {
return domains[a].PercentBlocked > domains[b].PercentBlocked
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
slog.Warn(
"DNS stats for pihole: did not get expected 144 data points",
"len(queries)", len(responseJson.QueriesSeries),
"len(blocked)", len(responseJson.BlockedSeries),
)
return stats, nil
}
var lowestTimestamp int64 = 0
for timestamp := range responseJson.QueriesSeries {
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
lowestTimestamp = timestamp
}
}
maxQueriesInSeries := 0
for i := 0; i < 8; i++ {
queries := 0
blocked := 0
for j := 0; j < 18; j++ {
index := lowestTimestamp + int64(i*10800+j*600)
queries += responseJson.QueriesSeries[index]
blocked += responseJson.BlockedSeries[index]
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
stats.Series[i] = DNSStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
}
for i := 0; i < 8; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View File

@ -1,247 +0,0 @@
package feed
import (
"math"
"sort"
"time"
)
type ForumPost struct {
Title string
DiscussionUrl string
TargetUrl string
TargetUrlDomain string
ThumbnailUrl string
CommentCount int
Score int
Engagement float64
TimePosted time.Time
Tags []string
IsCrosspost bool
}
type ForumPosts []ForumPost
type Calendar struct {
CurrentDay int
CurrentWeekNumber int
CurrentMonthName string
CurrentYear int
Days []int
}
type Weather struct {
Temperature int
ApparentTemperature int
WeatherCode int
CurrentColumn int
SunriseColumn int
SunsetColumn int
Columns []weatherColumn
}
type AppRelease struct {
Source ReleaseSource
SourceIconURL string
Name string
Version string
NotesUrl string
TimeReleased time.Time
Downvotes int
}
type AppReleases []AppRelease
type Video struct {
ThumbnailUrl string
Title string
Url string
Author string
AuthorUrl string
TimePosted time.Time
}
type Videos []Video
var currencyToSymbol = map[string]string{
"USD": "$",
"EUR": "€",
"JPY": "¥",
"CAD": "C$",
"AUD": "A$",
"GBP": "£",
"CHF": "Fr",
"NZD": "N$",
"INR": "₹",
"BRL": "R$",
"RUB": "₽",
"TRY": "₺",
"ZAR": "R",
"CNY": "¥",
"KRW": "₩",
"HKD": "HK$",
"SGD": "S$",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"PHP": "₱",
}
type DNSStats struct {
TotalQueries int
BlockedQueries int
BlockedPercent int
ResponseTime int
DomainsBlocked int
Series [8]DNSStatsSeries
TopBlockedDomains []DNSStatsBlockedDomain
}
type DNSStatsSeries struct {
Queries int
Blocked int
PercentTotal int
PercentBlocked int
}
type DNSStatsBlockedDomain struct {
Domain string
PercentBlocked int
}
type MarketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"`
Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"`
}
type Markets []Market
func (t Markets) SortByAbsChange() {
sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
})
}
func (t Markets) SortByChange() {
sort.Slice(t, func(i, j int) bool {
return t[i].PercentChange > t[j].PercentChange
})
}
var weatherCodeTable = map[int]string{
0: "Clear Sky",
1: "Mainly Clear",
2: "Partly Cloudy",
3: "Overcast",
45: "Fog",
48: "Rime Fog",
51: "Drizzle",
53: "Drizzle",
55: "Drizzle",
56: "Drizzle",
57: "Drizzle",
61: "Rain",
63: "Moderate Rain",
65: "Heavy Rain",
66: "Freezing Rain",
67: "Freezing Rain",
71: "Snow",
73: "Moderate Snow",
75: "Heavy Snow",
77: "Snow Grains",
80: "Rain",
81: "Moderate Rain",
82: "Heavy Rain",
85: "Snow",
86: "Snow",
95: "Thunderstorm",
96: "Thunderstorm",
99: "Thunderstorm",
}
func (w *Weather) WeatherCodeAsString() string {
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
return weatherCode
}
return ""
}
const depreciatePostsOlderThanHours = 7
const maxDepreciation = 0.9
const maxDepreciationAfterHours = 24
func (p ForumPosts) CalculateEngagement() {
var totalComments int
var totalScore int
for i := range p {
totalComments += p[i].CommentCount
totalScore += p[i].Score
}
numberOfPosts := float64(len(p))
averageComments := float64(totalComments) / numberOfPosts
averageScore := float64(totalScore) / numberOfPosts
for i := range p {
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
elapsed := time.Since(p[i].TimePosted)
if elapsed < time.Hour*depreciatePostsOlderThanHours {
continue
}
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
}
}
func (p ForumPosts) SortByEngagement() {
sort.Slice(p, func(i, j int) bool {
return p[i].Engagement > p[j].Engagement
})
}
func (s *ForumPost) HasTargetUrl() bool {
return s.TargetUrl != ""
}
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
recent := make([]ForumPost, 0, len(p))
for i := range p {
if time.Since(p[i].TimePosted) < postedBefore {
recent = append(recent, p[i])
}
}
return recent
}
func (r AppReleases) SortByNewest() AppReleases {
sort.Slice(r, func(i, j int) bool {
return r[i].TimeReleased.After(r[j].TimeReleased)
})
return r
}
func (v Videos) SortByNewest() Videos {
sort.Slice(v, func(i, j int) bool {
return v[i].TimePosted.After(v[j].TimePosted)
})
return v
}

View File

@ -1,72 +0,0 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
ReleaseSourceCodeberg ReleaseSource = "codeberg"
ReleaseSourceGithub ReleaseSource = "github"
ReleaseSourceGitlab ReleaseSource = "gitlab"
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
)
type ReleaseRequest struct {
Source ReleaseSource
Repository string
Token *string
}
func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
releases := make(AppReleases, 0, len(requests))
for i := range results {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
continue
}
releases = append(releases, *results[i])
}
if failed == len(requests) {
return nil, ErrNoContent
}
releases.SortByNewest()
if failed > 0 {
return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
}
return releases, nil
}
func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
switch request.Source {
case ReleaseSourceCodeberg:
return fetchLatestCodebergRelease(request)
case ReleaseSourceGithub:
return fetchLatestGithubRelease(request)
case ReleaseSourceGitlab:
return fetchLatestGitLabRelease(request)
case ReleaseSourceDockerHub:
return fetchLatestDockerHubRelease(request)
}
return nil, errors.New("unsupported source")
}

View File

@ -1,104 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
)
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
} `json:"meta"`
Indicators struct {
Quote []struct {
Close []float64 `json:"close,omitempty"`
} `json:"quote"`
} `json:"indicators"`
} `json:"result"`
} `json:"chart"`
}
// TODO: allow changing chart time frame
const marketChartDays = 21
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(marketRequests))
for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
markets := make(Markets, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
response := responses[i]
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
previous = prices[len(prices)-2]
}
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
if !exists {
currency = response.Chart.Result[0].Meta.Currency
}
markets = append(markets, Market{
MarketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
),
SvgChartPoints: points,
})
}
if len(markets) == 0 {
return nil, ErrNoContent
}
if failed > 0 {
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
}
return markets, nil
}

View File

@ -1,115 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
type youtubeFeedResponseXml struct {
Channel string `xml:"author>name"`
ChannelLink string `xml:"author>uri"`
Videos []struct {
Title string `xml:"title"`
Published string `xml:"published"`
Link struct {
Href string `xml:"href,attr"`
} `xml:"link"`
Group struct {
Thumbnail struct {
Url string `xml:"url,attr"`
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
} `xml:"http://search.yahoo.com/mrss/ group"`
} `xml:"entry"`
}
func parseYoutubeFeedTime(t string) time.Time {
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
if err != nil {
return time.Now()
}
return parsedTime
}
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds {
var feedUrl string
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
} else {
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
}
request, _ := http.NewRequest("GET", feedUrl, nil)
requests = append(requests, request)
}
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
videos := make(Videos, 0, len(channelIds)*15)
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
continue
}
response := responses[i]
for j := range response.Videos {
video := &response.Videos[j]
var videoUrl string
if videoUrlTemplate == "" {
videoUrl = video.Link.Href
} else {
parsedUrl, err := url.Parse(video.Link.Href)
if err == nil {
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
} else {
videoUrl = "#"
}
}
videos = append(videos, Video{
ThumbnailUrl: video.Group.Thumbnail.Url,
Title: video.Title,
Url: videoUrl,
Author: response.Channel,
AuthorUrl: response.ChannelLink + "/videos",
TimePosted: parseYoutubeFeedTime(video.Published),
})
}
}
if len(videos) == 0 {
return nil, ErrNoContent
}
videos.SortByNewest()
if failed > 0 {
return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
}
return videos, nil
}

View File

@ -2,41 +2,66 @@ package glance
import ( import (
"flag" "flag"
"fmt"
"os" "os"
"strings"
) )
type CliIntent uint8 type cliIntent uint8
const ( const (
CliIntentServe CliIntent = iota cliIntentServe cliIntent = iota
CliIntentCheckConfig = iota cliIntentConfigValidate = iota
cliIntentConfigPrint = iota
cliIntentDiagnose = iota
) )
type CliOptions struct { type cliOptions struct {
Intent CliIntent intent cliIntent
ConfigPath string configPath string
} }
func ParseCliOptions() (*CliOptions, error) { func parseCliOptions() (*cliOptions, error) {
flags := flag.NewFlagSet("", flag.ExitOnError) flags := flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() {
fmt.Println("Usage: glance [options] command")
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid") fmt.Println("\nOptions:")
flags.PrintDefaults()
fmt.Println("\nCommands:")
fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" diagnose Run diagnostic checks")
}
configPath := flags.String("config", "glance.yml", "Set config path") configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:]) err := flags.Parse(os.Args[1:])
if err != nil { if err != nil {
return nil, err return nil, err
} }
intent := CliIntentServe var intent cliIntent
var args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
if *checkConfig { if len(args) == 0 {
intent = CliIntentCheckConfig intent = cliIntentServe
} else if len(args) == 1 {
if args[0] == "config:validate" {
intent = cliIntentConfigValidate
} else if args[0] == "config:print" {
intent = cliIntentConfigPrint
} else if args[0] == "diagnose" {
intent = cliIntentDiagnose
} else {
return nil, unknownCommandErr
}
} else {
return nil, unknownCommandErr
} }
return &CliOptions{ return &cliOptions{
Intent: intent, intent: intent,
ConfigPath: *configPath, configPath: *configPath,
}, nil }, nil
} }

View File

@ -1,4 +1,4 @@
package widget package glance
import ( import (
"fmt" "fmt"
@ -12,70 +12,66 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
const ( const (
HSLHueMax = 360 hslHueMax = 360
HSLSaturationMax = 100 hslSaturationMax = 100
HSLLightnessMax = 100 hslLightnessMax = 100
) )
type HSLColorField struct { type hslColorField struct {
Hue uint16 Hue uint16
Saturation uint8 Saturation uint8
Lightness uint8 Lightness uint8
} }
func (c *HSLColorField) String() string { func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
} }
func (c *HSLColorField) AsCSSValue() template.CSS { func (c *hslColorField) AsCSSValue() template.CSS {
return template.CSS(c.String()) return template.CSS(c.String())
} }
func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
var value string var value string
if err := node.Decode(&value); err != nil { if err := node.Decode(&value); err != nil {
return err return err
} }
matches := HSLColorPattern.FindStringSubmatch(value) matches := hslColorFieldPattern.FindStringSubmatch(value)
if len(matches) != 4 { if len(matches) != 4 {
return fmt.Errorf("invalid HSL color format: %s", value) return fmt.Errorf("invalid HSL color format: %s", value)
} }
hue, err := strconv.ParseUint(matches[1], 10, 16) hue, err := strconv.ParseUint(matches[1], 10, 16)
if err != nil { if err != nil {
return err return err
} }
if hue > HSLHueMax { if hue > hslHueMax {
return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax) return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
} }
saturation, err := strconv.ParseUint(matches[2], 10, 8) saturation, err := strconv.ParseUint(matches[2], 10, 8)
if err != nil { if err != nil {
return err return err
} }
if saturation > HSLSaturationMax { if saturation > hslSaturationMax {
return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax) return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
} }
lightness, err := strconv.ParseUint(matches[3], 10, 8) lightness, err := strconv.ParseUint(matches[3], 10, 8)
if err != nil { if err != nil {
return err return err
} }
if lightness > HSLLightnessMax { if lightness > hslLightnessMax {
return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax) return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
} }
c.Hue = uint16(hue) c.Hue = uint16(hue)
@ -85,77 +81,76 @@ func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error {
return nil return nil
} }
var DurationPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
type DurationField time.Duration type durationField time.Duration
func (d *DurationField) UnmarshalYAML(node *yaml.Node) error { func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string var value string
if err := node.Decode(&value); err != nil { if err := node.Decode(&value); err != nil {
return err return err
} }
matches := DurationPattern.FindStringSubmatch(value) matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 { if len(matches) != 3 {
return fmt.Errorf("invalid duration format: %s", value) return fmt.Errorf("invalid duration format: %s", value)
} }
duration, err := strconv.Atoi(matches[1]) duration, err := strconv.Atoi(matches[1])
if err != nil { if err != nil {
return err return err
} }
switch matches[2] { switch matches[2] {
case "s": case "s":
*d = DurationField(time.Duration(duration) * time.Second) *d = durationField(time.Duration(duration) * time.Second)
case "m": case "m":
*d = DurationField(time.Duration(duration) * time.Minute) *d = durationField(time.Duration(duration) * time.Minute)
case "h": case "h":
*d = DurationField(time.Duration(duration) * time.Hour) *d = durationField(time.Duration(duration) * time.Hour)
case "d": case "d":
*d = DurationField(time.Duration(duration) * 24 * time.Hour) *d = durationField(time.Duration(duration) * 24 * time.Hour)
} }
return nil return nil
} }
type OptionalEnvString string var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { type optionalEnvField string
func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error {
var value string var value string
err := node.Decode(&value) err := node.Decode(&value)
if err != nil { if err != nil {
return err return err
} }
replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string { replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string {
if err != nil { if err != nil {
return "" return ""
} }
groups := EnvFieldPattern.FindStringSubmatch(whole) groups := optionalEnvFieldPattern.FindStringSubmatch(match)
if len(groups) != 3 { if len(groups) != 3 {
return whole return match
} }
prefix, key := groups[1], groups[2] prefix, key := groups[1], groups[2]
if prefix == `\` { if prefix == `\` {
if len(whole) >= 2 { if len(match) >= 2 {
return whole[1:] return match[1:]
} else { } else {
return "" return ""
} }
} }
value, found := os.LookupEnv(key) value, found := os.LookupEnv(key)
if !found { if !found {
err = fmt.Errorf("environment variable %s not found", key) err = fmt.Errorf("environment variable %s not found", key)
return "" return ""
@ -168,16 +163,16 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return err return err
} }
*f = OptionalEnvString(replaced) *f = optionalEnvField(replaced)
return nil return nil
} }
func (f *OptionalEnvString) String() string { func (f *optionalEnvField) String() string {
return string(*f) return string(*f)
} }
type CustomIcon struct { type customIconField struct {
URL string URL string
IsFlatIcon bool IsFlatIcon bool
// TODO: along with whether the icon is flat, we also need to know // TODO: along with whether the icon is flat, we also need to know
@ -185,8 +180,13 @@ type CustomIcon struct {
// invert the color based on the theme being light or dark // invert the color based on the theme being light or dark
} }
func (i *CustomIcon) FromURL(url string) error { func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
prefix, icon, found := strings.Cut(url, ":") var value string
if err := node.Decode(&value); err != nil {
return err
}
prefix, icon, found := strings.Cut(value, ":")
if !found { if !found {
i.URL = url i.URL = url
return nil return nil

View File

@ -1,43 +1,91 @@
package glance package glance
import ( import (
"bytes"
"fmt" "fmt"
"io" "html/template"
"log"
"maps"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { type config struct {
Server Server `yaml:"server"` Server struct {
Theme Theme `yaml:"theme"` Host string `yaml:"host"`
Branding Branding `yaml:"branding"` Port uint16 `yaml:"port"`
Pages []Page `yaml:"pages"` AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
StartedAt time.Time `yaml:"-"` // used in custom css file
} `yaml:"server"`
Document struct {
Head template.HTML `yaml:"head"`
} `yaml:"document"`
Theme struct {
BackgroundColor *hslColorField `yaml:"background-color"`
PrimaryColor *hslColorField `yaml:"primary-color"`
PositiveColor *hslColorField `yaml:"positive-color"`
NegativeColor *hslColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
} `yaml:"theme"`
Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
} `yaml:"branding"`
Pages []page `yaml:"pages"`
} }
func NewConfigFromYml(contents io.Reader) (*Config, error) { type page struct {
config := NewConfig() Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []struct {
Size string `yaml:"size"`
Widgets widgets `yaml:"widgets"`
} `yaml:"columns"`
PrimaryColumnIndex int8 `yaml:"-"`
mu sync.Mutex `yaml:"-"`
}
contentBytes, err := io.ReadAll(contents) func newConfigFromYAML(contents []byte) (*config, error) {
config := &config{}
config.Server.Port = 8080
err := yaml.Unmarshal(contents, config)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = yaml.Unmarshal(contentBytes, config) if err = isConfigStateValid(config); err != nil {
if err != nil {
return nil, err
}
if err = configIsValid(config); err != nil {
return nil, err return nil, err
} }
for p := range config.Pages { for p := range config.Pages {
for c := range config.Pages[p].Columns { for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets { for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil { if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
return nil, err return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
} }
} }
} }
@ -46,36 +94,213 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return config, nil return config, nil
} }
func NewConfig() *Config { func formatWidgetInitError(err error, w widget) error {
config := &Config{} return fmt.Errorf("%s widget: %v", w.GetType(), err)
config.Server.Host = ""
config.Server.Port = 8080
return config
} }
func configIsValid(config *Config) error { var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
mainFileContents, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
}
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
}
mainFileDir := filepath.Dir(mainFileAbsPath)
includes := make(map[string]struct{})
var includesLastErr error
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if includesLastErr != nil {
return nil
}
matches := includePattern.FindSubmatch(match)
if len(matches) != 3 {
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
return nil
}
indent := string(matches[1])
includeFilePath := strings.TrimSpace(string(matches[2]))
if !filepath.IsAbs(includeFilePath) {
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
}
var fileContents []byte
var err error
fileContents, err = os.ReadFile(includeFilePath)
if err != nil {
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
return nil
}
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents)))
})
if includesLastErr != nil {
return nil, nil, includesLastErr
}
return mainFileContents, includes, nil
}
func configFilesWatcher(
mainFilePath string,
lastContents []byte,
lastIncludes map[string]struct{},
onChange func(newContents []byte),
onErr func(error),
) (func() error, error) {
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
}
// TODO: refactor, flaky
lastIncludes[mainFileAbsPath] = struct{}{}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating watcher: %w", err)
}
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
for filePath := range previousWatched {
if _, ok := newWatched[filePath]; !ok {
watcher.Remove(filePath)
}
}
for filePath := range newWatched {
if _, ok := previousWatched[filePath]; !ok {
if err := watcher.Add(filePath); err != nil {
log.Printf(
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
filePath, err,
)
}
}
}
}
updateWatchedFiles(nil, lastIncludes)
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
mu := sync.Mutex{}
checkForContentChangesBeforeCallback := func() {
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
if err != nil {
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
return
}
// TODO: refactor, flaky
currentIncludes[mainFileAbsPath] = struct{}{}
mu.Lock()
defer mu.Unlock()
if !maps.Equal(currentIncludes, lastIncludes) {
updateWatchedFiles(lastIncludes, currentIncludes)
lastIncludes = currentIncludes
}
if !bytes.Equal(lastContents, currentContents) {
lastContents = currentContents
onChange(currentContents)
}
}
const debounceDuration = 500 * time.Millisecond
var debounceTimer *time.Timer
debouncedCallback := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer.Reset(debounceDuration)
} else {
debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
}
}
go func() {
for {
select {
case event, isOpen := <-watcher.Events:
if !isOpen {
return
}
if event.Has(fsnotify.Write) {
debouncedCallback()
} else if event.Has(fsnotify.Remove) {
func() {
mu.Lock()
defer mu.Unlock()
fileAbsPath, _ := filepath.Abs(event.Name)
delete(lastIncludes, fileAbsPath)
}()
debouncedCallback()
}
case err, isOpen := <-watcher.Errors:
if !isOpen {
return
}
onErr(fmt.Errorf("watcher error: %w", err))
}
}
}()
onChange(lastContents)
return func() error {
if debounceTimer != nil {
debounceTimer.Stop()
}
return watcher.Close()
}, nil
}
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
}
if config.Server.AssetsPath != "" {
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
}
}
for i := range config.Pages { for i := range config.Pages {
if config.Pages[i].Title == "" { if config.Pages[i].Title == "" {
return fmt.Errorf("Page %d has no title", i+1) return fmt.Errorf("page %d has no name", i+1)
} }
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1) return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
} }
if len(config.Pages[i].Columns) == 0 { if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1) return fmt.Errorf("page %d has no columns", i+1)
} }
if config.Pages[i].Width == "slim" { if config.Pages[i].Width == "slim" {
if len(config.Pages[i].Columns) > 2 { if len(config.Pages[i].Columns) > 2 {
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1) return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
} }
} else { } else {
if len(config.Pages[i].Columns) > 3 { if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) return fmt.Errorf("page %d has more than 3 columns", i+1)
} }
} }
@ -83,7 +308,7 @@ func configIsValid(config *Config) error {
for j := range config.Pages[i].Columns { for j := range config.Pages[i].Columns {
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1) return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
} }
columnSizesCount[config.Pages[i].Columns[j].Size]++ columnSizesCount[config.Pages[i].Columns[j].Size]++
@ -92,7 +317,7 @@ func configIsValid(config *Config) error {
full := columnSizesCount["full"] full := columnSizesCount["full"]
if full > 2 || full == 0 { if full > 2 || full == 0 {
return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1) return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
} }
} }

205
internal/glance/diagnose.go Normal file
View File

@ -0,0 +1,205 @@
package glance
import (
"context"
"fmt"
"io"
"net"
"net/http"
"runtime"
"strings"
"sync"
"time"
)
const httpTestRequestTimeout = 10 * time.Second
var diagnosticSteps = []diagnosticStep{
{
name: "resolve cloudflare.com through Cloudflare DoH",
fn: func() (string, error) {
return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
"accept": "application/dns-json",
}, 200)
},
},
{
name: "resolve cloudflare.com through Google DoH",
fn: func() (string, error) {
return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
},
},
{
name: "resolve github.com",
fn: func() (string, error) {
return testDNSResolution("github.com")
},
},
{
name: "resolve reddit.com",
fn: func() (string, error) {
return testDNSResolution("reddit.com")
},
},
{
name: "resolve twitch.tv",
fn: func() (string, error) {
return testDNSResolution("twitch.tv")
},
},
{
name: "fetch data from YouTube RSS feed",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
},
},
{
name: "fetch data from Twitch.tv GQL",
fn: func() (string, error) {
// this should always return 0 bytes, we're mainly looking for a 200 status code
return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
},
},
{
name: "fetch data from GitHub API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://api.github.com", 200)
},
},
{
name: "fetch data from Open-Meteo API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
},
},
{
name: "fetch data from Reddit API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
},
},
{
name: "fetch data from Yahoo finance API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
},
},
{
name: "fetch data from Hacker News Firebase API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
},
},
{
name: "fetch data from Docker Hub API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
},
},
}
func runDiagnostic() {
fmt.Println("```")
fmt.Println("Glance version: " + buildVersion)
fmt.Println("Go version: " + runtime.Version())
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
var wg sync.WaitGroup
for i := range diagnosticSteps {
step := &diagnosticSteps[i]
wg.Add(1)
go func() {
defer wg.Done()
start := time.Now()
step.extraInfo, step.err = step.fn()
step.elapsed = time.Since(start)
}()
}
wg.Wait()
for _, step := range diagnosticSteps {
var extraInfo string
if step.extraInfo != "" {
extraInfo = "| " + step.extraInfo + " "
}
fmt.Printf(
"%s %s %s| %dms\n",
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
step.name,
extraInfo,
step.elapsed.Milliseconds(),
)
if step.err != nil {
fmt.Printf("└╴ error: %v\n", step.err)
}
}
fmt.Println("```")
}
type diagnosticStep struct {
name string
fn func() (string, error)
extraInfo string
err error
elapsed time.Duration
}
func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
}
func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, method, url, nil)
for key, value := range headers {
request.Header.Add(key, value)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
printableBody := strings.ReplaceAll(string(body), "\n", "")
if len(printableBody) > 50 {
printableBody = printableBody[:50] + "..."
}
if len(printableBody) > 0 {
printableBody = ", " + printableBody
}
extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
if response.StatusCode != expectedStatusCode {
return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
}
return extraInfo, nil
}
func testDNSResolution(domain string) (string, error) {
ips, err := net.LookupIP(domain)
var ipStrings []string
if err == nil {
for i := range ips {
ipStrings = append(ipStrings, ips[i].String())
}
}
return strings.Join(ipStrings, ", "), err
}

62
internal/glance/embed.go Normal file
View File

@ -0,0 +1,62 @@
package glance
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log"
"strconv"
"time"
)
//go:embed static
var _staticFS embed.FS
//go:embed templates
var _templateFS embed.FS
var staticFS, _ = fs.Sub(_staticFS, "static")
var templateFS, _ = fs.Sub(_templateFS, "templates")
var staticFSHash = func() string {
hash, err := computeFSHash(staticFS)
if err != nil {
log.Printf("Could not compute static assets cache key: %v", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
return hash
}()
func computeFSHash(files fs.FS) (string, error) {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil))[:10], nil
}

View File

@ -5,140 +5,48 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/widget"
) )
var buildVersion = "dev" var (
pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html")
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) type application struct {
Version string
Config config
ParsedThemeStyle template.HTML
type Application struct { slugToPage map[string]*page
Version string widgetByID map[uint64]widget
Config Config
slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
} }
type Theme struct { func newApplication(config *config) (*application, error) {
BackgroundColor *widget.HSLColorField `yaml:"background-color"` app := &application{
PrimaryColor *widget.HSLColorField `yaml:"primary-color"`
PositiveColor *widget.HSLColorField `yaml:"positive-color"`
NegativeColor *widget.HSLColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
}
type Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
AssetsHash string `yaml:"-"`
StartedAt time.Time `yaml:"-"` // used in custom css file
}
type Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
}
type Column struct {
Size string `yaml:"size"`
Widgets widget.Widgets `yaml:"widgets"`
}
type templateData struct {
App *Application
Page *Page
}
type Page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []Column `yaml:"columns"`
PrimaryColumnIndex int8 `yaml:"-"`
mu sync.Mutex
}
func (p *Page) UpdateOutdatedWidgets() {
now := time.Now()
var wg sync.WaitGroup
context := context.Background()
for c := range p.Columns {
for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w]
if !widget.RequiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(context)
}()
}
}
wg.Wait()
}
// TODO: fix, currently very simple, lots of uncovered edge cases
func titleToSlug(s string) string {
s = strings.ToLower(s)
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func (a *Application) TransformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
return path
}
func NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured")
}
app := &Application{
Version: buildVersion, Version: buildVersion,
Config: *config, Config: *config,
slugToPage: make(map[string]*Page), slugToPage: make(map[string]*page),
widgetByID: make(map[uint64]widget.Widget), widgetByID: make(map[uint64]widget),
} }
app.Config.Server.AssetsHash = assets.PublicFSHash
app.slugToPage[""] = &config.Pages[0] app.slugToPage[""] = &config.Pages[0]
providers := &widget.Providers{ providers := &widgetProviders{
AssetResolver: app.AssetPath, assetResolver: app.AssetPath,
}
var err error
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
if err != nil {
return nil, fmt.Errorf("parsing theme style: %v", err)
} }
for p := range config.Pages { for p := range config.Pages {
@ -160,9 +68,9 @@ func NewApplication(config *Config) (*Application, error) {
for w := range column.Widgets { for w := range column.Widgets {
widget := column.Widgets[w] widget := column.Widgets[w]
app.widgetByID[widget.GetID()] = widget app.widgetByID[widget.id()] = widget
widget.SetProviders(providers) widget.setProviders(providers)
} }
} }
} }
@ -170,35 +78,75 @@ func NewApplication(config *Config) (*Application, error) {
config = &app.Config config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile) config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" { if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png") config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else { } else {
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL) config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
} }
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil return app, nil
} }
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { func (p *page) updateOutdatedWidgets() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
var wg sync.WaitGroup
context := context.Background()
for c := range p.Columns {
for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w]
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.update(context)
}()
}
}
wg.Wait()
}
func (a *application) transformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
return path
}
type pageTemplateData struct {
App *application
Page *page
}
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")] page, exists := a.slugToPage[r.PathValue("page")]
if !exists { if !exists {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
pageData := templateData{ pageData := pageTemplateData{
Page: page, Page: page,
App: a, App: a,
} }
var responseBytes bytes.Buffer var responseBytes bytes.Buffer
err := assets.PageTemplate.Execute(&responseBytes, pageData) err := pageTemplate.Execute(&responseBytes, pageData)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -208,25 +156,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
w.Write(responseBytes.Bytes()) w.Write(responseBytes.Bytes())
} }
func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) { func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")] page, exists := a.slugToPage[r.PathValue("page")]
if !exists { if !exists {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
pageData := templateData{ pageData := pageTemplateData{
Page: page, Page: page,
} }
page.mu.Lock() page.updateOutdatedWidgets()
defer page.mu.Unlock()
page.UpdateOutdatedWidgets()
var responseBytes bytes.Buffer var responseBytes bytes.Buffer
err := assets.PageContentTemplate.Execute(&responseBytes, pageData) err := pageContentTemplate.Execute(&responseBytes, pageData)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -236,74 +181,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes()) w.Write(responseBytes.Bytes())
} }
func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) { func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
// TODO: add proper not found page // TODO: add proper not found page
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Page not found")) w.Write([]byte("Page not found"))
} }
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
server := http.FileServer(fs)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: fix always setting cache control even if the file doesn't exist
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
server.ServeHTTP(w, r)
})
}
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget") widgetValue := r.PathValue("widget")
widgetID, err := strconv.ParseUint(widgetValue, 10, 64) widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil { if err != nil {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
widget, exists := a.widgetByID[widgetID] widget, exists := a.widgetByID[widgetID]
if !exists { if !exists {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
widget.HandleRequest(w, r) widget.handleRequest(w, r)
} }
func (a *Application) AssetPath(asset string) string { func (a *application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
} }
func (a *Application) Serve() error { func (a *application) server() (func() error, func() error) {
// TODO: add gzip support, static files must have their gzipped contents cached // TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support // TODO: add HTTPS support
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.HandlePageRequest) mux.HandleFunc("GET /{$}", a.handlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest) mux.HandleFunc("GET /{page}", a.handlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest) mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
mux.Handle( mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
) )
var absAssetsPath string
if a.Config.Server.AssetsPath != "" { if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
if err != nil {
return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
}
slog.Info("Serving assets", "path", absAssetsPath)
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
} }
@ -312,8 +241,25 @@ func (a *Application) Serve() error {
Handler: mux, Handler: mux,
} }
a.Config.Server.StartedAt = time.Now() start := func() error {
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL) a.Config.Server.StartedAt = time.Now()
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
a.Config.Server.Host,
a.Config.Server.Port,
a.Config.Server.BaseURL,
absAssetsPath,
)
return server.ListenAndServe() if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
stop := func() error {
return server.Close()
}
return start, stop
} }

View File

@ -2,45 +2,174 @@ package glance
import ( import (
"fmt" "fmt"
"io"
"log"
"net/http"
"os" "os"
) )
func Main() int { var buildVersion = "dev"
options, err := ParseCliOptions()
func Main() int {
options, err := parseCliOptions()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return 1 return 1
} }
configFile, err := os.Open(options.ConfigPath) switch options.intent {
case cliIntentServe:
if err != nil { // remove in v0.10.0
fmt.Printf("failed opening config file: %v\n", err) if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
return 1 return 1
} }
config, err := NewConfigFromYml(configFile)
configFile.Close()
if err != nil {
fmt.Printf("failed parsing config file: %v\n", err)
return 1
}
if options.Intent == CliIntentServe {
app, err := NewApplication(config)
if err := serveApp(options.configPath); err != nil {
fmt.Println(err)
return 1
}
case cliIntentConfigValidate:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil { if err != nil {
fmt.Printf("failed creating application: %v\n", err) fmt.Printf("Could not parse config file: %v\n", err)
return 1 return 1
} }
if err := app.Serve(); err != nil { if _, err := newConfigFromYAML(contents); err != nil {
fmt.Printf("http server error: %v\n", err) fmt.Printf("Config file is invalid: %v\n", err)
return 1 return 1
} }
case cliIntentConfigPrint:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil {
fmt.Printf("Could not parse config file: %v\n", err)
return 1
}
fmt.Println(string(contents))
case cliIntentDiagnose:
runDiagnostic()
} }
return 0 return 0
} }
func serveApp(configPath string) error {
exitChannel := make(chan struct{})
// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
hadValidConfigOnStartup := false
var stopServer func() error
onChange := func(newContents []byte) {
if stopServer != nil {
log.Println("Config file changed, reloading...")
}
config, err := newConfigFromYAML(newContents)
if err != nil {
log.Printf("Config has errors: %v", err)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
} else if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
app, err := newApplication(config)
if err != nil {
log.Printf("Failed to create application: %v", err)
return
}
if stopServer != nil {
if err := stopServer(); err != nil {
log.Printf("Error while trying to stop server: %v", err)
}
}
go func() {
var startServer func() error
startServer, stopServer = app.server()
if err := startServer(); err != nil {
log.Printf("Failed to start server: %v", err)
}
}()
}
onErr := func(err error) {
log.Printf("Error watching config files: %v", err)
}
configContents, configIncludes, err := parseYAMLIncludes(configPath)
if err != nil {
return fmt.Errorf("parsing config: %w", err)
}
stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
if err == nil {
defer stopWatching()
} else {
log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
config, err := newConfigFromYAML(configContents)
if err != nil {
return fmt.Errorf("validating config file: %w", err)
}
app, err := newApplication(config)
if err != nil {
return fmt.Errorf("creating application: %w", err)
}
startServer, _ := app.server()
if err := startServer(); err != nil {
return fmt.Errorf("starting server: %w", err)
}
}
<-exitChannel
return nil
}
func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
if !isRunningInsideDockerContainer() {
return false
}
if _, err := os.Stat(configPath); err == nil {
return false
}
// glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory
if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() {
return false
}
templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
bodyContents, _ := io.ReadAll(templateFile)
// TODO: update - add link
fmt.Println("!!! WARNING !!!")
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(bodyContents))
})
server := http.Server{
Addr: ":8080",
Handler: mux,
}
server.ListenAndServe()
return true
}

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 300 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 802 B

After

Width:  |  Height:  |  Size: 802 B

View File

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 553 B

View File

@ -28,6 +28,9 @@ export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
// NOTE: inconsistent behavior between browsers when it comes to
// whether the newly opened tab gets focused or not, potentially
// depending on the event that this function is called from
export function openURLInNewTab(url, focus = true) { export function openURLInNewTab(url, focus = true) {
const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); const newWindow = window.open(url, '_blank', 'noopener,noreferrer');

View File

@ -525,6 +525,7 @@ kbd:active {
list-style: none; list-style: none;
position: relative; position: relative;
display: flex; display: flex;
z-index: 1;
} }
.details[open] .summary { .details[open] .summary {
@ -546,6 +547,10 @@ kbd:active {
opacity: 1; opacity: 1;
} }
.details:not([open]) .list-with-transition {
display: none;
}
.summary::after { .summary::after {
content: "◀"; content: "◀";
font-size: 1.2em; font-size: 1.2em;
@ -1106,7 +1111,6 @@ details[open] .summary::after {
.dns-stats-graph-gridlines-container { .dns-stats-graph-gridlines-container {
position: absolute; position: absolute;
z-index: -1;
inset: 0; inset: 0;
} }
@ -1133,7 +1137,6 @@ details[open] .summary::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 1px 0; inset: 1px 0;
z-index: -1;
opacity: 0; opacity: 0;
background: var(--color-text-base); background: var(--color-text-base);
transition: opacity .2s; transition: opacity .2s;
@ -1275,7 +1278,6 @@ details[open] .summary::after {
overflow: hidden; overflow: hidden;
mask-image: linear-gradient(0deg, transparent 40%, #000); mask-image: linear-gradient(0deg, transparent 40%, #000);
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000); -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
z-index: -1;
} }
.weather-column-rain::before { .weather-column-rain::before {

View File

@ -0,0 +1,56 @@
package glance
import (
"fmt"
"html/template"
"math"
"strconv"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var intl = message.NewPrinter(language.English)
var globalTemplateFunctions = template.FuncMap{
"formatViewerCount": formatViewerCount,
"formatNumber": intl.Sprint,
"absInt": func(i int) int {
return int(math.Abs(float64(i)))
},
"formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price)
},
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
},
}
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
t, err := template.New(primary).
Funcs(globalTemplateFunctions).
ParseFS(templateFS, append([]string{primary}, dependencies...)...)
if err != nil {
panic(err)
}
return t
}
func formatViewerCount(count int) string {
if count < 1_000 {
return strconv.Itoa(count)
}
if count < 10_000 {
return fmt.Sprintf("%.1fk", float64(count)/1_000)
}
if count < 1_000_000 {
return fmt.Sprintf("%dk", count/1_000)
}
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
}

View File

@ -11,13 +11,18 @@
</div> </div>
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue"> <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
{{ if .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
<div class="calendar-day">Mo</div> <div class="calendar-day">Mo</div>
<div class="calendar-day">Tu</div> <div class="calendar-day">Tu</div>
<div class="calendar-day">We</div> <div class="calendar-day">We</div>
<div class="calendar-day">Th</div> <div class="calendar-day">Th</div>
<div class="calendar-day">Fr</div> <div class="calendar-day">Fr</div>
<div class="calendar-day">Sa</div> <div class="calendar-day">Sa</div>
<div class="calendar-day">Su</div> {{ if not .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
</div> </div>
<div class="flex flex-wrap"> <div class="flex flex-wrap">

View File

@ -73,7 +73,7 @@
<summary class="summary">Top blocked domains</summary> <summary class="summary">Top blocked domains</summary>
<ul class="list list-gap-4 list-with-transition size-h5"> <ul class="list list-gap-4 list-with-transition size-h5">
{{ range .Stats.TopBlockedDomains }} {{ range .Stats.TopBlockedDomains }}
<li class="flex justify-between align-center"> <li class="flex justify-between">
<div class="text-truncate rtl">{{ .Domain }}</div> <div class="text-truncate rtl">{{ .Domain }}</div>
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div> <div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
</li> </li>

View File

@ -12,7 +12,7 @@
</svg> </svg>
{{ else if ne .ThumbnailUrl "" }} {{ else if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy"> <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if .HasTargetUrl }} {{ else if ne "" .TargetUrl }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)"> <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg> </svg>
@ -37,7 +37,7 @@
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li> <li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }} {{ if ne "" .TargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li> <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }} {{ end }}
</ul> </ul>

View File

@ -14,10 +14,13 @@
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }} {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
{{ define "document-head-after" }} {{ define "document-head-after" }}
{{ template "page-style-overrides.gotmpl" . }} {{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}"> <link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
{{ end }} {{ end }}
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
{{ end }} {{ end }}
{{ define "navigation-links" }} {{ define "navigation-links" }}

View File

@ -7,7 +7,7 @@
<div class="flex items-center gap-10"> <div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a> <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }} {{ if $.ShowSourceIcon }}
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy"> <img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }} {{ end }}
</div> </div>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">

View File

@ -1,58 +1,58 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a> <a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li> <li>{{ .Repository.Stars | formatNumber }} stars</li>
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li> <li>{{ .Repository.Forks | formatNumber }} forks</li>
</ul> </ul>
{{ if gt (len .RepositoryDetails.Commits) 0 }} {{ if gt (len .Repository.Commits) 0 }}
<hr class="margin-block-8"> <hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a> <a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3"> <div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .RepositoryDetails.Commits }} {{ range .Repository.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li> <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list list-gap-2 min-width-0"> <ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Commits }} {{ range .Repository.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li> <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }} {{ end }}
</ul> </ul>
</div> </div>
{{ end }} {{ end }}
{{ if gt (len .RepositoryDetails.PullRequests) 0 }} {{ if gt (len .Repository.PullRequests) 0 }}
<hr class="margin-block-8"> <hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a> <a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3"> <div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }} {{ range .Repository.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li> <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list list-gap-2 min-width-0"> <ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.PullRequests }} {{ range .Repository.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li> <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ end }} {{ end }}
</ul> </ul>
</div> </div>
{{ end }} {{ end }}
{{ if gt (len .RepositoryDetails.Issues) 0 }} {{ if gt (len .Repository.Issues) 0 }}
<hr class="margin-block-10"> <hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a> <a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3"> <div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }} {{ range .Repository.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li> <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list list-gap-2 min-width-0"> <ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Issues }} {{ range .Repository.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li> <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ end }} {{ end }}
</ul> </ul>
</div> </div>

View File

@ -0,0 +1,14 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.AsCSSValue }};{{ end }}
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.AsCSSValue }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.AsCSSValue }};{{ end }}
}
</style>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="static/main.css">
<title>Update notice</title>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
}
.content-bounds {
max-width: 700px;
margin-top: -10rem;
}
.comfy-line-height {
line-height: 1.9;
}
</style>
</head>
<body>
<!-- TODO: update - add links -->
<div class="content-bounds color-highlight">
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
<div class="widget-content-frame margin-top-10 padding-widget">
<p class="comfy-line-height">
The default location of glance.yml in the Docker image has
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
for instructions or visit the <a class="color-primary" href="#">release notes</a>
to find out more about why this change was necessary. Sorry for the inconvenience.
</p>
<p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
</div>
</div>
</body>
</html>

View File

@ -1,19 +1,19 @@
package feed package glance
import ( import (
"errors" "bytes"
"fmt" "fmt"
"html/template"
"net/http"
"net/url" "net/url"
"os"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
) )
var ( var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
ErrNoContent = errors.New("failed to retrieve any content")
ErrPartialContent = errors.New("failed to retrieve some of the content")
)
func percentChange(current, previous float64) float64 { func percentChange(current, previous float64) float64 {
return (current/previous - 1) * 100 return (current/previous - 1) * 100
@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string {
} }
parsed, err := url.Parse(u) parsed, err := url.Parse(u)
if err != nil { if err != nil {
return "" return ""
} }
@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string {
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
} }
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
if len(values) < 2 { if len(values) < 2 {
return "" return ""
} }
@ -86,6 +85,21 @@ func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "") return urlSchemePattern.ReplaceAllString(url, "")
} }
func isRunningInsideDockerContainer() bool {
_, err := os.Stat("/.dockerenv")
return err == nil
}
func prefixStringLines(prefix string, s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = prefix + line
}
return strings.Join(lines, "\n")
}
func limitStringLength(s string, max int) (string, bool) { func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s) asRunes := []rune(s)
@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) {
func parseRFC3339Time(t string) time.Time { func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t) parsed, err := time.Parse(time.RFC3339, t)
if err != nil { if err != nil {
return time.Now() return time.Now()
} }
@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time {
return parsed return parsed
} }
func boolToString(b bool, trueValue, falseValue string) string {
if b {
return trueValue
}
return falseValue
}
func normalizeVersionFormat(version string) string { func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version)) version = strings.ToLower(strings.TrimSpace(version))
@ -115,3 +136,33 @@ func normalizeVersionFormat(version string) string {
return version return version
} }
func titleToSlug(s string) string {
s = strings.ToLower(s)
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
server := http.FileServer(fs)
cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: fix always setting cache control even if the file doesn't exist
w.Header().Set("Cache-Control", cacheControlValue)
server.ServeHTTP(w, r)
})
}
func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
var b bytes.Buffer
err := t.Execute(&b, data)
if err != nil {
return "", fmt.Errorf("executing template: %w", err)
}
return template.HTML(b.String()), nil
}

View File

@ -0,0 +1,34 @@
package glance
import (
"html/template"
)
var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
type bookmarksWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
Groups []struct {
Title string `yaml:"title"`
Color *hslColorField `yaml:"color"`
Links []struct {
Title string `yaml:"title"`
URL string `yaml:"url"`
Icon customIconField `yaml:"icon"`
SameTab bool `yaml:"same-tab"`
HideArrow bool `yaml:"hide-arrow"`
} `yaml:"links"`
} `yaml:"groups"`
}
func (widget *bookmarksWidget) initialize() error {
widget.withTitle("Bookmarks").withError(nil)
widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
return nil
}
func (widget *bookmarksWidget) Render() template.HTML {
return widget.cachedHTML
}

View File

@ -0,0 +1,86 @@
package glance
import (
"context"
"html/template"
"time"
)
var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
type calendarWidget struct {
widgetBase `yaml:",inline"`
Calendar *calendar
StartSunday bool `yaml:"start-sunday"`
}
func (widget *calendarWidget) initialize() error {
widget.withTitle("Calendar").withCacheOnTheHour()
return nil
}
func (widget *calendarWidget) update(ctx context.Context) {
widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
widget.withError(nil).scheduleNextUpdate()
}
func (widget *calendarWidget) Render() template.HTML {
return widget.renderTemplate(widget, calendarWidgetTemplate)
}
type calendar struct {
CurrentDay int
CurrentWeekNumber int
CurrentMonthName string
CurrentYear int
Days []int
}
// TODO: very inflexible, refactor to allow more customizability
// TODO: allow changing between showing the previous and next week and the entire month
func newCalendar(now time.Time, startSunday bool) *calendar {
year, week := now.ISOWeek()
weekday := now.Weekday()
if !startSunday {
weekday = (weekday + 6) % 7 // Shift Monday to 0
}
currentMonthDays := daysInMonth(now.Month(), year)
var previousMonthDays int
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
previousMonthDays = daysInMonth(12, year-1)
} else {
previousMonthDays = daysInMonth(previousMonthNumber, year)
}
startDaysFrom := now.Day() - int(weekday) - 7
days := make([]int, 21)
for i := 0; i < 21; i++ {
day := startDaysFrom + i
if day < 1 {
day = previousMonthDays + day
} else if day > currentMonthDays {
day = day - currentMonthDays
}
days[i] = day
}
return &calendar{
CurrentDay: now.Day(),
CurrentWeekNumber: week,
CurrentMonthName: now.Month().String(),
CurrentYear: year,
Days: days,
}
}
func daysInMonth(m time.Month, year int) int {
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}

View File

@ -1,7 +1,9 @@
package feed package glance
import ( import (
"context"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"sort" "sort"
@ -9,7 +11,65 @@ import (
"time" "time"
) )
type ChangeDetectionWatch struct { var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html")
type changeDetectionWidget struct {
widgetBase `yaml:",inline"`
ChangeDetections changeDetectionWatchList `yaml:"-"`
WatchUUIDs []string `yaml:"watches"`
InstanceURL string `yaml:"instance-url"`
Token optionalEnvField `yaml:"token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
}
func (widget *changeDetectionWidget) initialize() error {
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
if widget.Limit <= 0 {
widget.Limit = 10
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if widget.InstanceURL == "" {
widget.InstanceURL = "https://www.changedetection.io"
}
return nil
}
func (widget *changeDetectionWidget) update(ctx context.Context) {
if len(widget.WatchUUIDs) == 0 {
uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.WatchUUIDs = uuids
}
watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if len(watches) > widget.Limit {
watches = watches[:widget.Limit]
}
widget.ChangeDetections = watches
}
func (widget *changeDetectionWidget) Render() template.HTML {
return widget.renderTemplate(widget, changeDetectionWidgetTemplate)
}
type changeDetectionWatch struct {
Title string Title string
URL string URL string
LastChanged time.Time LastChanged time.Time
@ -17,9 +77,9 @@ type ChangeDetectionWatch struct {
PreviousHash string PreviousHash string
} }
type ChangeDetectionWatches []ChangeDetectionWatch type changeDetectionWatchList []changeDetectionWatch
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches { func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
sort.Slice(r, func(i, j int) bool { sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged) return r[i].LastChanged.After(r[j].LastChanged)
}) })
@ -35,15 +95,14 @@ type changeDetectionResponseJson struct {
PreviousHash string `json:"previous_md5"` PreviousHash string `json:"previous_md5"`
} }
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) { func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil) request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" { if token != "" {
request.Header.Add("x-api-key", token) request.Header.Add("x-api-key", token)
} }
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request) uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err) return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
} }
@ -57,8 +116,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str
return uuids, nil return uuids, nil
} }
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) { func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs)) watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 { if len(requestedWatchIDs) == 0 {
return watches, nil return watches, nil
@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
requests[i] = request requests[i] = request
} }
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient) task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(15) job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job) responses, errs, err := workerPoolDo(job)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
for i := range responses { for i := range responses {
if errs[i] != nil { if errs[i] != nil {
failed++ failed++
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL) slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i])
continue continue
} }
watchJson := responses[i] watchJson := responses[i]
watch := ChangeDetectionWatch{ watch := changeDetectionWatch{
URL: watchJson.URL, URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1), DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
} }
@ -126,13 +184,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
} }
if len(watches) == 0 { if len(watches) == 0 {
return nil, ErrNoContent return nil, errNoContent
} }
watches.SortByNewest() watches.sortByNewest()
if failed > 0 { if failed > 0 {
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed) return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed)
} }
return watches, nil return watches, nil

View File

@ -1,15 +1,15 @@
package widget package glance
import ( import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"time" "time"
"github.com/glanceapp/glance/internal/assets"
) )
type Clock struct { var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
type clockWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"` cachedHTML template.HTML `yaml:"-"`
HourFormat string `yaml:"hour-format"` HourFormat string `yaml:"hour-format"`
@ -19,32 +19,30 @@ type Clock struct {
} `yaml:"timezones"` } `yaml:"timezones"`
} }
func (widget *Clock) Initialize() error { func (widget *clockWidget) initialize() error {
widget.withTitle("Clock").withError(nil) widget.withTitle("Clock").withError(nil)
if widget.HourFormat == "" { if widget.HourFormat == "" {
widget.HourFormat = "24h" widget.HourFormat = "24h"
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" { } else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
return errors.New("invalid hour format for clock widget, must be either 12h or 24h") return errors.New("hour-format must be either 12h or 24h")
} }
for t := range widget.Timezones { for t := range widget.Timezones {
if widget.Timezones[t].Timezone == "" { if widget.Timezones[t].Timezone == "" {
return errors.New("missing timezone value for clock widget") return errors.New("missing timezone value")
} }
_, err := time.LoadLocation(widget.Timezones[t].Timezone) if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
if err != nil {
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
} }
} }
widget.cachedHTML = widget.render(widget, assets.ClockTemplate) widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
return nil return nil
} }
func (widget *Clock) Render() template.HTML { func (widget *clockWidget) Render() template.HTML {
return widget.cachedHTML return widget.cachedHTML
} }

View File

@ -0,0 +1,58 @@
package glance
import (
"context"
"sync"
"time"
)
type containerWidgetBase struct {
Widgets widgets `yaml:"widgets"`
}
func (widget *containerWidgetBase) _initializeWidgets() error {
for i := range widget.Widgets {
if err := widget.Widgets[i].initialize(); err != nil {
return formatWidgetInitError(err, widget.Widgets[i])
}
}
return nil
}
func (widget *containerWidgetBase) _update(ctx context.Context) {
var wg sync.WaitGroup
now := time.Now()
for w := range widget.Widgets {
widget := widget.Widgets[w]
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.update(ctx)
}()
}
wg.Wait()
}
func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) {
for i := range widget.Widgets {
widget.Widgets[i].setProviders(providers)
}
}
func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool {
for i := range widget.Widgets {
if widget.Widgets[i].requiresUpdate(now) {
return true
}
}
return false
}

View File

@ -0,0 +1,208 @@
package glance
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"time"
"github.com/tidwall/gjson"
)
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
type customAPIWidget struct {
widgetBase `yaml:",inline"`
URL optionalEnvField `yaml:"url"`
Template string `yaml:"template"`
Frameless bool `yaml:"frameless"`
Headers map[string]optionalEnvField `yaml:"headers"`
APIRequest *http.Request `yaml:"-"`
compiledTemplate *template.Template `yaml:"-"`
CompiledHTML template.HTML `yaml:"-"`
}
func (widget *customAPIWidget) initialize() error {
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
if widget.URL == "" {
return errors.New("URL is required")
}
if widget.Template == "" {
return errors.New("template is required")
}
compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template)
if err != nil {
return fmt.Errorf("parsing template: %w", err)
}
widget.compiledTemplate = compiledTemplate
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
if err != nil {
return err
}
for key, value := range widget.Headers {
req.Header.Add(key, value.String())
}
widget.APIRequest = req
return nil
}
func (widget *customAPIWidget) update(ctx context.Context) {
compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.CompiledHTML = compiledHTML
}
func (widget *customAPIWidget) Render() template.HTML {
return widget.renderTemplate(widget, customAPIWidgetTemplate)
}
func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
emptyBody := template.HTML("")
resp, err := defaultHTTPClient.Do(req)
if err != nil {
return emptyBody, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return emptyBody, err
}
body := string(bodyBytes)
if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON")
}
var templateBuffer bytes.Buffer
data := CustomAPITemplateData{
JSON: decoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
err = tmpl.Execute(&templateBuffer, &data)
if err != nil {
return emptyBody, err
}
return template.HTML(templateBuffer.String()), nil
}
type decoratedGJSONResult struct {
gjson.Result
}
type CustomAPITemplateData struct {
JSON decoratedGJSONResult
Response *http.Response
}
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
decoratedResults := make([]decoratedGJSONResult, len(results))
for i, result := range results {
decoratedResults[i] = decoratedGJSONResult{result}
}
return decoratedResults
}
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
if key == "" {
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
}
func (r *decoratedGJSONResult) String(key string) string {
if key == "" {
return r.Result.String()
}
return r.Get(key).String()
}
func (r *decoratedGJSONResult) Int(key string) int64 {
if key == "" {
return r.Result.Int()
}
return r.Get(key).Int()
}
func (r *decoratedGJSONResult) Float(key string) float64 {
if key == "" {
return r.Result.Float()
}
return r.Get(key).Float()
}
func (r *decoratedGJSONResult) Bool(key string) bool {
if key == "" {
return r.Result.Bool()
}
return r.Get(key).Bool()
}
var customAPITemplateFuncs = func() template.FuncMap {
funcs := template.FuncMap{
"toFloat": func(a int64) float64 {
return float64(a)
},
"toInt": func(a float64) int64 {
return int64(a)
},
"mathexpr": func(left float64, op string, right float64) float64 {
if right == 0 {
return 0
}
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
return left / right
default:
return 0
}
},
}
for key, value := range globalTemplateFunctions {
funcs[key] = value
}
return funcs
}()

View File

@ -0,0 +1,352 @@
package glance
import (
"context"
"encoding/json"
"errors"
"html/template"
"log/slog"
"net/http"
"sort"
"strings"
"time"
)
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
type dnsStatsWidget struct {
widgetBase `yaml:",inline"`
TimeLabels [8]string `yaml:"-"`
Stats *dnsStats `yaml:"-"`
HourFormat string `yaml:"hour-format"`
Service string `yaml:"service"`
AllowInsecure bool `yaml:"allow-insecure"`
URL optionalEnvField `yaml:"url"`
Token optionalEnvField `yaml:"token"`
Username optionalEnvField `yaml:"username"`
Password optionalEnvField `yaml:"password"`
}
func makeDNSWidgetTimeLabels(format string) [8]string {
now := time.Now()
var labels [8]string
for h := 24; h > 0; h -= 3 {
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
}
return labels
}
func (widget *dnsStatsWidget) initialize() error {
widget.
withTitle("DNS Stats").
withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute)
if widget.Service != "adguard" && widget.Service != "pihole" {
return errors.New("service must be either 'adguard' or 'pihole'")
}
return nil
}
func (widget *dnsStatsWidget) update(ctx context.Context) {
var stats *dnsStats
var err error
if widget.Service == "adguard" {
stats, err = fetchAdguardStats(string(widget.URL), widget.AllowInsecure, string(widget.Username), string(widget.Password))
} else {
stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, string(widget.Token))
}
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.HourFormat == "24h" {
widget.TimeLabels = makeDNSWidgetTimeLabels("15:00")
} else {
widget.TimeLabels = makeDNSWidgetTimeLabels("3PM")
}
widget.Stats = stats
}
func (widget *dnsStatsWidget) Render() template.HTML {
return widget.renderTemplate(widget, dnsStatsWidgetTemplate)
}
type dnsStats struct {
TotalQueries int
BlockedQueries int
BlockedPercent int
ResponseTime int
DomainsBlocked int
Series [8]dnsStatsSeries
TopBlockedDomains []dnsStatsBlockedDomain
}
type dnsStatsSeries struct {
Queries int
Blocked int
PercentTotal int
PercentBlocked int
}
type dnsStatsBlockedDomain struct {
Domain string
PercentBlocked int
}
type adguardStatsResponse struct {
TotalQueries int `json:"num_dns_queries"`
QueriesSeries []int `json:"dns_queries"`
BlockedQueries int `json:"num_blocked_filtering"`
BlockedSeries []int `json:"blocked_filtering"`
ResponseTime float64 `json:"avg_processing_time"`
TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
}
func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string) (*dnsStats, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.SetBasicAuth(username, password)
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
if err != nil {
return nil, err
}
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
stats := &dnsStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
ResponseTime: int(responseJson.ResponseTime * 1000),
TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
for i := 0; i < topBlockedDomainsCount; i++ {
domain := responseJson.TopBlockedDomains[i]
var firstDomain string
for k := range domain {
firstDomain = k
break
}
if firstDomain == "" {
continue
}
stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
Domain: firstDomain,
})
if stats.BlockedQueries > 0 {
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
}
}
queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries
const bars = 8
const hoursSpan = 24
const hoursPerBar int = hoursSpan / bars
if len(queriesSeries) > hoursSpan {
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
} else if len(queriesSeries) < hoursSpan {
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
}
if len(blockedSeries) > hoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
}
maxQueriesInSeries := 0
for i := 0; i < bars; i++ {
queries := 0
blocked := 0
for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j]
}
stats.Series[i] = dnsStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < bars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}
type piholeStatsResponse struct {
TotalQueries int `json:"dns_queries_today"`
QueriesSeries map[int64]int `json:"domains_over_time"`
BlockedQueries int `json:"ads_blocked_today"`
BlockedSeries map[int64]int `json:"ads_over_time"`
BlockedPercentage float64 `json:"ads_percentage_today"`
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
DomainsBlocked int `json:"domains_being_blocked"`
}
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
type piholeTopBlockedDomains map[string]int
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
// because of the UnmarshalJSON method getting called recursively
temp := make(map[string]int)
err := json.Unmarshal(data, &temp)
if err != nil {
*p = make(piholeTopBlockedDomains)
} else {
*p = temp
}
return nil
}
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string) (*dnsStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceURL, "/") +
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
if err != nil {
return nil, err
}
stats := &dnsStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
BlockedPercent: int(responseJson.BlockedPercentage),
DomainsBlocked: responseJson.DomainsBlocked,
}
if len(responseJson.TopBlockedDomains) > 0 {
domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
for domain, count := range responseJson.TopBlockedDomains {
domains = append(domains, dnsStatsBlockedDomain{
Domain: domain,
PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
})
}
sort.Slice(domains, func(a, b int) bool {
return domains[a].PercentBlocked > domains[b].PercentBlocked
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
slog.Warn(
"DNS stats for pihole: did not get expected 144 data points",
"len(queries)", len(responseJson.QueriesSeries),
"len(blocked)", len(responseJson.BlockedSeries),
)
return stats, nil
}
var lowestTimestamp int64 = 0
for timestamp := range responseJson.QueriesSeries {
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
lowestTimestamp = timestamp
}
}
maxQueriesInSeries := 0
for i := 0; i < 8; i++ {
queries := 0
blocked := 0
for j := 0; j < 18; j++ {
index := lowestTimestamp + int64(i*10800+j*600)
queries += responseJson.QueriesSeries[index]
blocked += responseJson.BlockedSeries[index]
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
stats.Series[i] = dnsStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
}
for i := 0; i < 8; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View File

@ -0,0 +1,152 @@
package glance
import (
"context"
"errors"
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"time"
)
var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html")
type extensionWidget struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
Extension extension `yaml:"-"`
cachedHTML template.HTML `yaml:"-"`
}
func (widget *extensionWidget) initialize() error {
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
if widget.URL == "" {
return errors.New("URL is required")
}
if _, err := url.Parse(widget.URL); err != nil {
return fmt.Errorf("parsing URL: %v", err)
}
return nil
}
func (widget *extensionWidget) update(ctx context.Context) {
extension, err := fetchExtension(extensionRequestOptions{
URL: widget.URL,
FallbackContentType: widget.FallbackContentType,
Parameters: widget.Parameters,
AllowHtml: widget.AllowHtml,
})
widget.canContinueUpdateAfterHandlingErr(err)
widget.Extension = extension
if extension.Title != "" {
widget.Title = extension.Title
}
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
}
func (widget *extensionWidget) Render() template.HTML {
return widget.cachedHTML
}
type extensionType int
const (
extensionContentHTML extensionType = iota
extensionContentUnknown = iota
)
var extensionStringToType = map[string]extensionType{
"html": extensionContentHTML,
}
const (
extensionHeaderTitle = "Widget-Title"
extensionHeaderContentType = "Widget-Content-Type"
)
type extensionRequestOptions struct {
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML {
switch contentType {
case extensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func fetchExtension(options extensionRequestOptions) (extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("Failed fetching extension", "url", options.URL, "error", err)
return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("Failed reading response body of extension", "url", options.URL, "error", err)
return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err)
}
extension := extension{}
if response.Header.Get(extensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(extensionHeaderTitle)
}
contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
if !ok {
contentType, ok = extensionStringToType[options.FallbackContentType]
if !ok {
contentType = extensionContentUnknown
}
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View File

@ -0,0 +1,52 @@
package glance
import (
"context"
"errors"
"html/template"
"time"
)
var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html")
type groupWidget struct {
widgetBase `yaml:",inline"`
containerWidgetBase `yaml:",inline"`
}
func (widget *groupWidget) initialize() error {
widget.withError(nil)
widget.HideHeader = true
for i := range widget.Widgets {
widget.Widgets[i].setHideHeader(true)
if widget.Widgets[i].GetType() == "group" {
return errors.New("nested groups are not supported")
} else if widget.Widgets[i].GetType() == "split-column" {
return errors.New("split columns inside of groups are not supported")
}
}
if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
return err
}
return nil
}
func (widget *groupWidget) update(ctx context.Context) {
widget.containerWidgetBase._update(ctx)
}
func (widget *groupWidget) setProviders(providers *widgetProviders) {
widget.containerWidgetBase._setProviders(providers)
}
func (widget *groupWidget) requiresUpdate(now *time.Time) bool {
return widget.containerWidgetBase._requiresUpdate(now)
}
func (widget *groupWidget) Render() template.HTML {
return widget.renderTemplate(widget, groupWidgetTemplate)
}

View File

@ -0,0 +1,152 @@
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
type hackerNewsWidget struct {
widgetBase `yaml:",inline"`
Posts forumPostList `yaml:"-"`
Limit int `yaml:"limit"`
SortBy string `yaml:"sort-by"`
ExtraSortBy string `yaml:"extra-sort-by"`
CollapseAfter int `yaml:"collapse-after"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *hackerNewsWidget) initialize() error {
widget.
withTitle("Hacker News").
withTitleURL("https://news.ycombinator.com/").
withCacheDuration(30 * time.Minute)
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
widget.SortBy = "top"
}
return nil
}
func (widget *hackerNewsWidget) update(ctx context.Context) {
posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.ExtraSortBy == "engagement" {
posts.calculateEngagement()
posts.sortByEngagement()
}
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}
func (widget *hackerNewsWidget) Render() template.HTML {
return widget.renderTemplate(widget, forumPostsTemplate)
}
type hackerNewsPostResponseJson struct {
Id int `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
TargetUrl string `json:"url,omitempty"`
CommentCount int `json:"descendants"`
TimePosted int64 `json:"time"`
}
func fetchHackerNewsPostIds(sort string) ([]int, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
}
return response, nil
}
func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
requests := make([]*http.Request, len(postIds))
for i, id := range postIds {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
requests[i] = request
}
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(30)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
posts := make(forumPostList, 0, len(postIds))
for i := range results {
if errs[i] != nil {
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
}
posts = append(posts, forumPost{
Title: results[i].Title,
DiscussionUrl: commentsUrl,
TargetUrl: results[i].TargetUrl,
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
CommentCount: results[i].CommentCount,
Score: results[i].Score,
TimePosted: time.Unix(results[i].TimePosted, 0),
})
}
if len(posts) == 0 {
return nil, errNoContent
}
if len(posts) != len(postIds) {
return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
}
return posts, nil
}
func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
postIds, err := fetchHackerNewsPostIds(sort)
if err != nil {
return nil, err
}
if len(postIds) > limit {
postIds = postIds[:limit]
}
return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View File

@ -1,20 +1,20 @@
package widget package glance
import ( import (
"html/template" "html/template"
) )
type HTML struct { type htmlWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Source template.HTML `yaml:"source"` Source template.HTML `yaml:"source"`
} }
func (widget *HTML) Initialize() error { func (widget *htmlWidget) initialize() error {
widget.withTitle("").withError(nil) widget.withTitle("").withError(nil)
return nil return nil
} }
func (widget *HTML) Render() template.HTML { func (widget *htmlWidget) Render() template.HTML {
return widget.Source return widget.Source
} }

View File

@ -1,32 +1,30 @@
package widget package glance
import ( import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"net/url" "net/url"
"github.com/glanceapp/glance/internal/assets"
) )
type IFrame struct { var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html")
type iframeWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"` cachedHTML template.HTML `yaml:"-"`
Source string `yaml:"source"` Source string `yaml:"source"`
Height int `yaml:"height"` Height int `yaml:"height"`
} }
func (widget *IFrame) Initialize() error { func (widget *iframeWidget) initialize() error {
widget.withTitle("IFrame").withError(nil) widget.withTitle("IFrame").withError(nil)
if widget.Source == "" { if widget.Source == "" {
return errors.New("missing source for iframe") return errors.New("source is required")
} }
_, err := url.Parse(widget.Source) if _, err := url.Parse(widget.Source); err != nil {
return fmt.Errorf("parsing URL: %v", err)
if err != nil {
return fmt.Errorf("invalid source for iframe: %v", err)
} }
if widget.Height == 50 { if widget.Height == 50 {
@ -35,11 +33,11 @@ func (widget *IFrame) Initialize() error {
widget.Height = 50 widget.Height = 50
} }
widget.cachedHTML = widget.render(widget, assets.IFrameTemplate) widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
return nil return nil
} }
func (widget *IFrame) Render() template.HTML { func (widget *iframeWidget) Render() template.HTML {
return widget.cachedHTML return widget.cachedHTML
} }

View File

@ -0,0 +1,144 @@
package glance
import (
"context"
"html/template"
"net/http"
"strings"
"time"
)
type lobstersWidget struct {
widgetBase `yaml:",inline"`
Posts forumPostList `yaml:"-"`
InstanceURL string `yaml:"instance-url"`
CustomURL string `yaml:"custom-url"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"`
Tags []string `yaml:"tags"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *lobstersWidget) initialize() error {
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
if widget.InstanceURL == "" {
widget.withTitleURL("https://lobste.rs")
} else {
widget.withTitleURL(widget.InstanceURL)
}
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
widget.SortBy = "hot"
}
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
return nil
}
func (widget *lobstersWidget) update(ctx context.Context) {
posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}
func (widget *lobstersWidget) Render() template.HTML {
return widget.renderTemplate(widget, forumPostsTemplate)
}
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, err
}
posts := make(forumPostList, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, forumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, errNoContent
}
return posts, nil
}
func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
}
posts, err := fetchLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}

View File

@ -0,0 +1,205 @@
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"math"
"net/http"
"sort"
"time"
)
var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
type marketsWidget struct {
widgetBase `yaml:",inline"`
StocksRequests []marketRequest `yaml:"stocks"`
MarketRequests []marketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"`
Markets marketList `yaml:"-"`
}
func (widget *marketsWidget) initialize() error {
widget.withTitle("Markets").withCacheDuration(time.Hour)
// legacy support, remove in v0.10.0
if len(widget.MarketRequests) == 0 {
widget.MarketRequests = widget.StocksRequests
}
return nil
}
func (widget *marketsWidget) update(ctx context.Context) {
markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Sort == "absolute-change" {
markets.sortByAbsChange()
}
if widget.Sort == "change" {
markets.sortByChange()
}
widget.Markets = markets
}
func (widget *marketsWidget) Render() template.HTML {
return widget.renderTemplate(widget, marketsWidgetTemplate)
}
type marketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type market struct {
marketRequest
Currency string
Price float64
PercentChange float64
SvgChartPoints string
}
type marketList []market
func (t marketList) sortByAbsChange() {
sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
})
}
func (t marketList) sortByChange() {
sort.Slice(t, func(i, j int) bool {
return t[i].PercentChange > t[j].PercentChange
})
}
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
} `json:"meta"`
Indicators struct {
Quote []struct {
Close []float64 `json:"close,omitempty"`
} `json:"quote"`
} `json:"indicators"`
} `json:"result"`
} `json:"chart"`
}
// TODO: allow changing chart time frame
const marketChartDays = 21
func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) {
requests := make([]*http.Request, 0, len(marketRequests))
for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
markets := make(marketList, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
response := responses[i]
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
previous = prices[len(prices)-2]
}
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
if !exists {
currency = response.Chart.Result[0].Meta.Currency
}
markets = append(markets, market{
marketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
),
SvgChartPoints: points,
})
}
if len(markets) == 0 {
return nil, errNoContent
}
if failed > 0 {
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed)
}
return markets, nil
}
var currencyToSymbol = map[string]string{
"USD": "$",
"EUR": "€",
"JPY": "¥",
"CAD": "C$",
"AUD": "A$",
"GBP": "£",
"CHF": "Fr",
"NZD": "N$",
"INR": "₹",
"BRL": "R$",
"RUB": "₽",
"TRY": "₺",
"ZAR": "R",
"CNY": "¥",
"KRW": "₩",
"HKD": "HK$",
"SGD": "S$",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"PHP": "₱",
}

View File

@ -0,0 +1,176 @@
package glance
import (
"context"
"errors"
"html/template"
"net/http"
"slices"
"strconv"
"time"
)
var (
monitorWidgetTemplate = mustParseTemplate("monitor.html", "widget-base.html")
monitorWidgetCompactTemplate = mustParseTemplate("monitor-compact.html", "widget-base.html")
)
type monitorWidget struct {
widgetBase `yaml:",inline"`
Sites []struct {
*SiteStatusRequest `yaml:",inline"`
Status *SiteStatus `yaml:"-"`
Title string `yaml:"title"`
Icon customIconField `yaml:"icon"`
SameTab bool `yaml:"same-tab"`
StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`
AltStatusCodes []int `yaml:"alt-status-codes"`
} `yaml:"sites"`
Style string `yaml:"style"`
ShowFailingOnly bool `yaml:"show-failing-only"`
HasFailing bool `yaml:"-"`
}
func (widget *monitorWidget) initialize() error {
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
return nil
}
func (widget *monitorWidget) update(ctx context.Context) {
requests := make([]*SiteStatusRequest, len(widget.Sites))
for i := range widget.Sites {
requests[i] = widget.Sites[i].SiteStatusRequest
}
statuses, err := fetchStatusForSites(requests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.HasFailing = false
for i := range widget.Sites {
site := &widget.Sites[i]
status := &statuses[i]
site.Status = status
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
widget.HasFailing = true
}
if !status.TimedOut {
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
}
}
}
func (widget *monitorWidget) Render() template.HTML {
if widget.Style == "compact" {
return widget.renderTemplate(widget, monitorWidgetCompactTemplate)
}
return widget.renderTemplate(widget, monitorWidgetTemplate)
}
func statusCodeToText(status int, altStatusCodes []int) string {
if status == 200 || slices.Contains(altStatusCodes, status) {
return "OK"
}
if status == 404 {
return "Not Found"
}
if status == 403 {
return "Forbidden"
}
if status == 401 {
return "Unauthorized"
}
if status >= 400 {
return "Client Error"
}
if status >= 500 {
return "Server Error"
}
return strconv.Itoa(status)
}
func statusCodeToStyle(status int, altStatusCodes []int) string {
if status == 200 || slices.Contains(altStatusCodes, status) {
return "ok"
}
return "error"
}
type SiteStatusRequest struct {
URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct {
Code int
TimedOut bool
ResponseTime time.Duration
Error error
}
func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
requestSentAt := time.Now()
var response *http.Response
if !statusRequest.AllowInsecure {
response, err = defaultHTTPClient.Do(request)
} else {
response, err = defaultInsecureHTTPClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
status.TimedOut = true
}
status.Error = err
return status, nil
}
defer response.Body.Close()
status.Code = response.StatusCode
return status, nil
}
func fetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(fetchSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job)
if err != nil {
return nil, err
}
return results, nil
}

View File

@ -1,14 +1,131 @@
package feed package glance
import ( import (
"context"
"errors"
"fmt" "fmt"
"html" "html"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
) )
var (
redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html")
redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html")
)
type redditWidget struct {
widgetBase `yaml:",inline"`
Posts forumPostList `yaml:"-"`
Subreddit string `yaml:"subreddit"`
Style string `yaml:"style"`
ShowThumbnails bool `yaml:"show-thumbnails"`
ShowFlairs bool `yaml:"show-flairs"`
SortBy string `yaml:"sort-by"`
TopPeriod string `yaml:"top-period"`
Search string `yaml:"search"`
ExtraSortBy string `yaml:"extra-sort-by"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
RequestUrlTemplate string `yaml:"request-url-template"`
}
func (widget *redditWidget) initialize() error {
if widget.Subreddit == "" {
return errors.New("subreddit is required")
}
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if !isValidRedditSortType(widget.SortBy) {
widget.SortBy = "hot"
}
if !isValidRedditTopPeriod(widget.TopPeriod) {
widget.TopPeriod = "day"
}
if widget.RequestUrlTemplate != "" {
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
return errors.New("no `{REQUEST-URL}` placeholder specified")
}
}
widget.
withTitle("/r/" + widget.Subreddit).
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
withCacheDuration(30 * time.Minute)
return nil
}
func isValidRedditSortType(sortBy string) bool {
return sortBy == "hot" ||
sortBy == "new" ||
sortBy == "top" ||
sortBy == "rising"
}
func isValidRedditTopPeriod(period string) bool {
return period == "hour" ||
period == "day" ||
period == "week" ||
period == "month" ||
period == "year" ||
period == "all"
}
func (widget *redditWidget) update(ctx context.Context) {
// TODO: refactor, use a struct to pass all of these
posts, err := fetchSubredditPosts(
widget.Subreddit,
widget.SortBy,
widget.TopPeriod,
widget.Search,
widget.CommentsUrlTemplate,
widget.RequestUrlTemplate,
widget.ShowFlairs,
)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if len(posts) > widget.Limit {
posts = posts[:widget.Limit]
}
if widget.ExtraSortBy == "engagement" {
posts.calculateEngagement()
posts.sortByEngagement()
}
widget.Posts = posts
}
func (widget *redditWidget) Render() template.HTML {
if widget.Style == "horizontal-cards" {
return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate)
}
if widget.Style == "vertical-cards" {
return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate)
}
return widget.renderTemplate(widget, forumPostsTemplate)
}
type subredditResponseJson struct { type subredditResponseJson struct {
Data struct { Data struct {
Children []struct { Children []struct {
@ -44,7 +161,7 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str
return template return template
} }
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) { func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
query := url.Values{} query := url.Values{}
var requestUrl string var requestUrl string
@ -68,15 +185,13 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
} }
request, err := http.NewRequest("GET", requestUrl, nil) request, err := http.NewRequest("GET", requestUrl, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
addBrowserUserAgentHeader(request) setBrowserUserAgentHeader(request)
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request) responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -85,7 +200,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
return nil, fmt.Errorf("no posts found") return nil, fmt.Errorf("no posts found")
} }
posts := make(ForumPosts, 0, len(responseJson.Data.Children)) posts := make(forumPostList, 0, len(responseJson.Data.Children))
for i := range responseJson.Data.Children { for i := range responseJson.Data.Children {
post := &responseJson.Data.Children[i].Data post := &responseJson.Data.Children[i].Data
@ -102,7 +217,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink) commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
} }
forumPost := ForumPost{ forumPost := forumPost{
Title: html.UnescapeString(post.Title), Title: html.UnescapeString(post.Title),
DiscussionUrl: commentsUrl, DiscussionUrl: commentsUrl,
TargetUrlDomain: post.Domain, TargetUrlDomain: post.Domain,

View File

@ -0,0 +1,394 @@
package glance
import (
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html")
type releasesWidget struct {
widgetBase `yaml:",inline"`
Releases appReleaseList `yaml:"-"`
releaseRequests []*releaseRequest `yaml:"-"`
Repositories []string `yaml:"repositories"`
Token optionalEnvField `yaml:"token"`
GitLabToken optionalEnvField `yaml:"gitlab-token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
ShowSourceIcon bool `yaml:"show-source-icon"`
}
func (widget *releasesWidget) initialize() error {
widget.withTitle("Releases").withCacheDuration(2 * time.Hour)
if widget.Limit <= 0 {
widget.Limit = 10
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
var tokenAsString = widget.Token.String()
var gitLabTokenAsString = widget.GitLabToken.String()
for _, repository := range widget.Repositories {
parts := strings.SplitN(repository, ":", 2)
var request *releaseRequest
if len(parts) == 1 {
request = &releaseRequest{
source: releaseSourceGithub,
repository: repository,
}
if widget.Token != "" {
request.token = &tokenAsString
}
} else if len(parts) == 2 {
if parts[0] == string(releaseSourceGitlab) {
request = &releaseRequest{
source: releaseSourceGitlab,
repository: parts[1],
}
if widget.GitLabToken != "" {
request.token = &gitLabTokenAsString
}
} else if parts[0] == string(releaseSourceDockerHub) {
request = &releaseRequest{
source: releaseSourceDockerHub,
repository: parts[1],
}
} else if parts[0] == string(releaseSourceCodeberg) {
request = &releaseRequest{
source: releaseSourceCodeberg,
repository: parts[1],
}
} else {
return errors.New("invalid repository source " + parts[0])
}
}
widget.releaseRequests = append(widget.releaseRequests, request)
}
return nil
}
func (widget *releasesWidget) update(ctx context.Context) {
releases, err := fetchLatestReleases(widget.releaseRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if len(releases) > widget.Limit {
releases = releases[:widget.Limit]
}
for i := range releases {
releases[i].SourceIconURL = widget.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg")
}
widget.Releases = releases
}
func (widget *releasesWidget) Render() template.HTML {
return widget.renderTemplate(widget, releasesWidgetTemplate)
}
type releaseSource string
const (
releaseSourceCodeberg releaseSource = "codeberg"
releaseSourceGithub releaseSource = "github"
releaseSourceGitlab releaseSource = "gitlab"
releaseSourceDockerHub releaseSource = "dockerhub"
)
type appRelease struct {
Source releaseSource
SourceIconURL string
Name string
Version string
NotesUrl string
TimeReleased time.Time
Downvotes int
}
type appReleaseList []appRelease
func (r appReleaseList) sortByNewest() appReleaseList {
sort.Slice(r, func(i, j int) bool {
return r[i].TimeReleased.After(r[j].TimeReleased)
})
return r
}
type releaseRequest struct {
source releaseSource
repository string
token *string
}
func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
releases := make(appReleaseList, 0, len(requests))
for i := range results {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].repository, "error", errs[i])
continue
}
releases = append(releases, *results[i])
}
if failed == len(requests) {
return nil, errNoContent
}
releases.sortByNewest()
if failed > 0 {
return releases, fmt.Errorf("%w: could not get %d releases", errPartialContent, failed)
}
return releases, nil
}
func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) {
switch request.source {
case releaseSourceCodeberg:
return fetchLatestCodebergRelease(request)
case releaseSourceGithub:
return fetchLatestGithubRelease(request)
case releaseSourceGitlab:
return fetchLatestGitLabRelease(request)
case releaseSourceDockerHub:
return fetchLatestDockerHubRelease(request)
}
return nil, errors.New("unsupported source")
}
type githubReleaseLatestResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
Reactions struct {
Downvotes int `json:"-1"`
} `json:"reactions"`
}
func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository),
nil,
)
if err != nil {
return nil, err
}
if request.token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
}
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
return &appRelease{
Source: releaseSourceGithub,
Name: request.repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
Downvotes: response.Reactions.Downvotes,
}, nil
}
type dockerHubRepositoryTagsResponse struct {
Results []dockerHubRepositoryTagResponse `json:"results"`
}
type dockerHubRepositoryTagResponse struct {
Name string `json:"name"`
LastPushed string `json:"tag_last_pushed"`
}
const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
nameParts := strings.Split(request.repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
tagParts := strings.SplitN(nameParts[1], ":", 2)
var requestURL string
if len(tagParts) == 2 {
requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
} else {
requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
}
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
if request.token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
}
var tag *dockerHubRepositoryTagResponse
if len(tagParts) == 1 {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
if len(response.Results) == 0 {
return nil, fmt.Errorf("no tags found for repository: %s", request.repository)
}
tag = &response.Results[0]
} else {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
tag = &response
}
var repo string
var displayName string
var notesURL string
if len(tagParts) == 1 {
repo = nameParts[1]
} else {
repo = tagParts[0]
}
if nameParts[0] == "library" {
displayName = repo
notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
} else {
displayName = nameParts[0] + "/" + repo
notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
}
return &appRelease{
Source: releaseSourceDockerHub,
NotesUrl: notesURL,
Name: displayName,
Version: tag.Name,
TimeReleased: parseRFC3339Time(tag.LastPushed),
}, nil
}
type gitlabReleaseResponseJson struct {
TagName string `json:"tag_name"`
ReleasedAt string `json:"released_at"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
}
func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
url.QueryEscape(request.repository),
),
nil,
)
if err != nil {
return nil, err
}
if request.token != nil {
httpRequest.Header.Add("PRIVATE-TOKEN", *request.token)
}
response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
return &appRelease{
Source: releaseSourceGitlab,
Name: request.repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.Links.Self,
TimeReleased: parseRFC3339Time(response.ReleasedAt),
}, nil
}
type codebergReleaseResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
}
func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://codeberg.org/api/v1/repos/%s/releases/latest",
request.repository,
),
nil,
)
if err != nil {
return nil, err
}
response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
return &appRelease{
Source: releaseSourceCodeberg,
Name: request.repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
}, nil
}

View File

@ -1,72 +1,91 @@
package feed package glance
import ( import (
"context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
type githubReleaseLatestResponseJson struct { var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html")
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"` type repositoryWidget struct {
HtmlUrl string `json:"html_url"` widgetBase `yaml:",inline"`
Reactions struct { RequestedRepository string `yaml:"repository"`
Downvotes int `json:"-1"` Token optionalEnvField `yaml:"token"`
} `json:"reactions"` PullRequestsLimit int `yaml:"pull-requests-limit"`
IssuesLimit int `yaml:"issues-limit"`
CommitsLimit int `yaml:"commits-limit"`
Repository repository `yaml:"-"`
} }
func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) { func (widget *repositoryWidget) initialize() error {
httpRequest, err := http.NewRequest( widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
"GET",
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository), if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
nil, widget.PullRequestsLimit = 3
}
if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
widget.IssuesLimit = 3
}
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
widget.CommitsLimit = -1
}
return nil
}
func (widget *repositoryWidget) update(ctx context.Context) {
details, err := fetchRepositoryDetailsFromGithub(
widget.RequestedRepository,
string(widget.Token),
widget.PullRequestsLimit,
widget.IssuesLimit,
widget.CommitsLimit,
) )
if err != nil { if !widget.canContinueUpdateAfterHandlingErr(err) {
return nil, err return
} }
if request.Token != nil { widget.Repository = details
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceGithub,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
Downvotes: response.Reactions.Downvotes,
}, nil
} }
type GithubTicket struct { func (widget *repositoryWidget) Render() template.HTML {
return widget.renderTemplate(widget, repositoryWidgetTemplate)
}
type repository struct {
Name string
Stars int
Forks int
OpenPullRequests int
PullRequests []githubTicket
OpenIssues int
Issues []githubTicket
LastCommits int
Commits []githubCommitDetails
}
type githubTicket struct {
Number int Number int
CreatedAt time.Time CreatedAt time.Time
Title string Title string
} }
type RepositoryDetails struct { type githubCommitDetails struct {
Name string Sha string
Stars int Author string
Forks int CreatedAt time.Time
OpenPullRequests int Message string
PullRequests []GithubTicket
OpenIssues int
Issues []GithubTicket
LastCommits int
Commits []CommitDetails
} }
type githubRepositoryDetailsResponseJson struct { type githubRepositoryResponseJson struct {
Name string `json:"full_name"` Name string `json:"full_name"`
Stars int `json:"stargazers_count"` Stars int `json:"stargazers_count"`
Forks int `json:"forks_count"` Forks int `json:"forks_count"`
@ -81,13 +100,6 @@ type githubTicketResponseJson struct {
} `json:"items"` } `json:"items"`
} }
type CommitDetails struct {
Sha string
Author string
CreatedAt time.Time
Message string
}
type gitHubCommitResponseJson struct { type gitHubCommitResponseJson struct {
Sha string `json:"sha"` Sha string `json:"sha"`
Commit struct { Commit struct {
@ -99,15 +111,15 @@ type gitHubCommitResponseJson struct {
} `json:"commit"` } `json:"commit"`
} }
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) { func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil)
if err != nil { if err != nil {
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err) return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err)
} }
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil) PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil)
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil) issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil)
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil) CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil)
if token != "" { if token != "" {
token = fmt.Sprintf("Bearer %s", token) token = fmt.Sprintf("Bearer %s", token)
@ -117,7 +129,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
CommitsRequest.Header.Add("Authorization", token) CommitsRequest.Header.Add("Authorization", token)
} }
var detailsResponse githubRepositoryDetailsResponseJson var repositoryResponse githubRepositoryResponseJson
var detailsErr error var detailsErr error
var PRsResponse githubTicketResponseJson var PRsResponse githubTicketResponseJson
var PRsErr error var PRsErr error
@ -130,14 +142,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
wg.Add(1) wg.Add(1)
go (func() { go (func() {
defer wg.Done() defer wg.Done()
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest) repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest)
})() })()
if maxPRs > 0 { if maxPRs > 0 {
wg.Add(1) wg.Add(1)
go (func() { go (func() {
defer wg.Done() defer wg.Done()
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest) PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest)
})() })()
} }
@ -145,7 +157,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
wg.Add(1) wg.Add(1)
go (func() { go (func() {
defer wg.Done() defer wg.Done()
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest) issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest)
})() })()
} }
@ -153,35 +165,35 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
wg.Add(1) wg.Add(1)
go (func() { go (func() {
defer wg.Done() defer wg.Done()
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest) commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest)
})() })()
} }
wg.Wait() wg.Wait()
if detailsErr != nil { if detailsErr != nil {
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr) return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr)
} }
details := RepositoryDetails{ details := repository{
Name: detailsResponse.Name, Name: repositoryResponse.Name,
Stars: detailsResponse.Stars, Stars: repositoryResponse.Stars,
Forks: detailsResponse.Forks, Forks: repositoryResponse.Forks,
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)), PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)),
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)), Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)),
Commits: make([]CommitDetails, 0, len(commitsResponse)), Commits: make([]githubCommitDetails, 0, len(commitsResponse)),
} }
err = nil err = nil
if maxPRs > 0 { if maxPRs > 0 {
if PRsErr != nil { if PRsErr != nil {
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr) err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr)
} else { } else {
details.OpenPullRequests = PRsResponse.Count details.OpenPullRequests = PRsResponse.Count
for i := range PRsResponse.Tickets { for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, GithubTicket{ details.PullRequests = append(details.PullRequests, githubTicket{
Number: PRsResponse.Tickets[i].Number, Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt), CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title, Title: PRsResponse.Tickets[i].Title,
@ -193,12 +205,12 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
if maxIssues > 0 { if maxIssues > 0 {
if issuesErr != nil { if issuesErr != nil {
// TODO: fix, overwriting the previous error // TODO: fix, overwriting the previous error
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr) err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr)
} else { } else {
details.OpenIssues = issuesResponse.Count details.OpenIssues = issuesResponse.Count
for i := range issuesResponse.Tickets { for i := range issuesResponse.Tickets {
details.Issues = append(details.Issues, GithubTicket{ details.Issues = append(details.Issues, githubTicket{
Number: issuesResponse.Tickets[i].Number, Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt), CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title, Title: issuesResponse.Tickets[i].Title,
@ -209,10 +221,10 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
if maxCommits > 0 { if maxCommits > 0 {
if CommitsErr != nil { if CommitsErr != nil {
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr) err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr)
} else { } else {
for i := range commitsResponse { for i := range commitsResponse {
details.Commits = append(details.Commits, CommitDetails{ details.Commits = append(details.Commits, githubCommitDetails{
Sha: commitsResponse[i].Sha, Sha: commitsResponse[i].Sha,
Author: commitsResponse[i].Commit.Author.Name, Author: commitsResponse[i].Commit.Author.Name,
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date), CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),

Some files were not shown because too many files have changed in this diff Show More