Compare commits

...
543 Commits
Author SHA1 Message Date
Svilen Markov 91324e8de7 Remove windows/arm build
Create release / release (push) Has been cancelled
2026-05-30 21:35:16 +01:00
Svilen Markov d63afc2c6d Work around Reddit's TLS handshake detection 2026-05-30 21:07:07 +01:00
Svilen Markov 315e39d61e Bump go and alpine versions 2026-05-30 19:42:44 +01:00
Svilen Markov b1077531bf Update readme 2026-05-30 19:42:32 +01:00
Svilen Markov 9ecc937dc4 Add a way to bypass Reddit API restrictions 2026-05-30 19:42:17 +01:00
Svilen Markov 7202b68733 Bump spoofed user agent versions 2026-05-30 19:40:37 +01:00
Svilen Markov 6c5b7a3f4c Update docs 2025-12-10 09:44:00 +00:00
Svilen Markov 36d5ae023f Merge pull request #848 from fullmetalsheep/main
feat(themes): Add theme 'Neon Pink'
2025-10-17 14:18:26 +01:00
fullmetalsheep 478c08f6a7 feat(themes): Add theme 'Neon Pink' 2025-10-17 10:53:25 +08:00
Svilen Markov cae90d16ba Update theme preview 2025-09-28 12:40:33 +01:00
Svilen Markov fbc07bd142 Merge pull request #833 from nicolasluckie/feat/add-shades-of-purple-theme
feat: Add Shades of Purple theme with screenshot
2025-09-28 12:36:36 +01:00
Svilen Markov 4a4d3e1755 Add contrast-multiplier to shades of purple 2025-09-28 12:34:41 +01:00
Svilen Markov f243a4938f Update readme 2025-09-28 12:21:12 +01:00
Svilen Markov 9416de1497 Fix indentation 2025-09-28 11:43:11 +01:00
Nic Luckie 283a5fcfd0 feat: add Shades of Purple theme with screenshot 2025-09-27 22:34:21 -04:00
Svilen Markov c88fd526e5 Merge branch 'dev'
Create release / release (push) Has been cancelled
2025-06-10 08:43:26 +01:00
Svilen Markov f0541ea5c8 Refactor icon stuff 2025-06-10 08:25:34 +01:00
Svilen Markov 8f986f1403 Refactor icons field and add mdi 2025-06-10 08:17:38 +01:00
Svilen Markov de9a192ba4 Remove healthcheck #676 2025-06-10 07:45:58 +01:00
Svilen Markov 88d8fa56fb Make auto-invert work with prefixed icons 2025-06-10 07:43:34 +01:00
Svilen Markov 9044e640bc Merge pull request #699 from septechx/patch-2
Fix typo in docs
2025-06-10 07:13:05 +01:00
Svilen Markov 808f3c1436 Update page.js 2025-06-10 07:11:25 +01:00
Svilen Markov d103c81df1 Fix search shortcut #719 2025-06-10 07:11:09 +01:00
Svilen Markov 429f0be675 Merge pull request #705 from rhijjawi/dev
setupTodos using incorrect logic and skipping to-do item.
2025-06-10 07:11:06 +01:00
Ramzi H 5a093f42b0 setupTodos using incorrect logic and skipping to-do item. 2025-05-27 12:54:22 +02:00
Sep ded435df0e Fix typo in docs 2025-05-25 23:34:31 +02:00
Svilen Markov e52374fa24 Fix popover triangle misalignment 2025-05-24 14:58:19 +01:00
Svilen Markov b94647efc9 Merge pull request #693 from chenrui333/update-purego
build: bump purego to build against newer go
2025-05-24 05:09:08 +01:00
Rui Chen d512770c10 build: bump purego to build against newer go
Signed-off-by: Rui Chen <rui@chenrui.dev>
2025-05-23 23:36:52 -04:00
Svilen Markov ea52318be6 Update FUNDING.yml 2025-05-24 00:03:17 +01:00
Svilen Markov 9e5023522b Merge pull request #689 from thallada/patch-1
Correct docs for Go's `text/html` built-in template functions
2025-05-22 23:32:33 +01:00
Tyler Hallada 4d0fdd9b11 Correct docs for Go's text/html built-in template functions
Source: https://pkg.go.dev/text/template#hdr-Functions

The comparison functions the greater-than-or-equal-to and less-than-or-equal-to are `ge` and `le`, not `gte` and `lte`. I got an error rendering my template before I realized this.
2025-05-22 18:03:45 -04:00
Svilen Markov 78725c8591 Merge pull request #687 from chenkhuaning0816/config
Docs: Add how to get playlist ID in configuration.md
2025-05-22 22:53:48 +01:00
acidburn f7adaad1c5 Docs: Add how to get playlist ID 2025-05-22 20:26:35 +08:00
Svilen Markov ab093cb232 Fix extra whitespace in titles (again) 2025-05-21 09:41:36 +01:00
Svilen Markov 2aaff02db8 Fix for extra whitespace in titles 2025-05-20 16:46:06 +01:00
Svilen Markov f5bfd9d4d1 Merge branch 'dev'
Create release / release (push) Has been cancelled
2025-05-19 21:25:48 +01:00
Svilen Markov 9e3639eb54 Update readme 2025-05-19 21:25:33 +01:00
Svilen Markov b4094b28bd Allow disabling theme picker 2025-05-19 21:25:33 +01:00
Svilen Markov b294839b79 Make theme key accessible via CSS 2025-05-19 21:25:33 +01:00
Svilen Markov 14a21de37f Add user agent to test reddit request 2025-05-19 21:23:13 +01:00
Svilen Markov d9239acbce Increase diagnose command timeout 2025-05-19 21:23:13 +01:00
Svilen Markov a2247c0b6c Use default client in diagnose command
defaultHTTPClient may behave differently to http.DefaultClient and is used for almost all widgets, using it within the diagnose
command will make debugging and replicating issues easier
2025-05-19 21:23:13 +01:00
Svilen Markov c1aaec5ffc Add .Options.JSON to custom API 2025-05-19 21:23:13 +01:00
Svilen Markov bcef9fbd61 Simplify implementation of func 2025-05-19 21:23:12 +01:00
Svilen Markov 32db59fda2 Merge pull request #678 from ralphocdol/fix-text-truncate-formatting
Fix text-truncate or related formatting
2025-05-19 21:12:21 +01:00
Svilen Markov 92bc68b61a Use innerText instead of textContent 2025-05-19 21:11:11 +01:00
Svilen Markov a6382b2e1d Merge pull request #681 from ralphocdol/update-the-and-or-doc-description
Docs: The 'and' and 'or' accepts more than 2 boolean arguments
2025-05-19 21:07:05 +01:00
Ralph Ocdol 571cdaf618 The 'and' and 'or' accepts more than 2 boolean arguments 2025-05-19 19:46:26 +08:00
Ralph Ocdol aa3b2c3b1b Fix text-truncate or related formatting 2025-05-18 20:33:03 +08:00
Svilen Markov 9bbf73db97 Merge pull request #661 from anant-j/dev
Add Mod operation
2025-05-16 17:33:58 +01:00
Svilen Markov dc950e7ec2 Merge pull request #668 from Xevion/main
Add Anchors to Links to Configuration Documentation within README
2025-05-16 17:30:14 +01:00
Svilen Markov 91ca57e242 Merge pull request #662 from mazzz1y/dev
Add http proxy support
2025-05-16 17:21:53 +01:00
Xevion 95541ef5e1 Use an anchor for links to root configuration 2025-05-15 23:19:54 -05:00
Svilen Markov 1ace129a58 Add safeHTML function 2025-05-15 14:21:59 +01:00
Svilen Markov af04605d7d Remove unused function 2025-05-15 13:57:27 +01:00
Dmitry Rubtsov 0ec9cf4aaa Add http proxy support 2025-05-15 13:34:17 +06:00
Anant Jain a5b0664b9c Add Mod operation 2025-05-14 22:38:13 -07:00
Svilen Markov c67eb4d2c0 Add startOfDay and endOfDay funcs
Create release / release (push) Has been cancelled
2025-05-14 22:05:35 +01:00
Svilen Markov e99ee4f774 Fix server crash when using head-widgets 2025-05-14 22:00:02 +01:00
Svilen Markov 74e6c5c960 Update docs 2025-05-14 01:16:53 +01:00
Svilen Markov f801da88a7 Fix center-vertically not working 2025-05-14 00:48:16 +01:00
Svilen Markov 66dcd1fa34 Don't escape safe URL 2025-05-13 23:44:47 +01:00
Svilen Markov 4f9e48cc17 Remove fallback favicon #653 2025-05-13 22:07:57 +01:00
Svilen Markov 95702a2e53 Fix error when hide-desktop-navigation is true
Create release / release (push) Has been cancelled
2025-05-13 20:35:34 +01:00
Svilen Markov 9de6843a2e Merge pull request #648 from glanceapp/dev
Create release / release (push) Has been cancelled
v0.8.0
2025-05-13 19:22:13 +01:00
Svilen Markov bd5cacd14a Update docs 2025-05-13 18:55:57 +01:00
Svilen Markov 96007e98bc Update docs 2025-05-13 17:35:16 +01:00
Svilen Markov ae409768ac Rename widget type 2025-05-13 17:10:28 +01:00
Svilen Markov baaf306ebf Add percentChange function 2025-05-11 21:09:12 +01:00
Svilen Markov 7bbf103e01 Add custom-api options and template requests 2025-05-11 15:49:21 +01:00
Svilen Markov 49c07f397e Don't escape safe URLs 2025-05-10 13:25:56 +01:00
Svilen Markov dd91a506fa Add todo widget 2025-05-08 16:59:02 +01:00
Svilen Markov 4ed8bef562 Update default light theme 2025-05-07 21:03:58 +01:00
Svilen Markov 8db6544400 Update favicon 2025-05-07 20:56:40 +01:00
Svilen Markov ca9e4d273b Fix popover error 2025-05-07 20:17:36 +01:00
Svilen Markov 9324da6a2f Change default light theme 2025-05-07 20:17:30 +01:00
Svilen Markov ce3a7ee29c Update auth stuff 2025-05-07 19:47:35 +01:00
Svilen Markov c9e6b774f3 Add head-widgets 2025-05-06 13:37:14 +01:00
Svilen Markov 9ffb2d9939 Change default light theme 2025-05-06 11:31:57 +01:00
Svilen Markov c5d4cf8f68 Allow hiding widget header 2025-05-06 10:52:46 +01:00
Svilen Markov 6b7d68d960 Add auth 2025-05-06 01:38:22 +01:00
Svilen Markov 0cb8a810e6 Remove transition on active 2025-05-05 13:50:55 +01:00
Svilen Markov 40ae263248 Add expiration to theme cookie 2025-05-05 13:46:36 +01:00
Svilen Markov bf97829814 Make theme cookie samesite lax 2025-05-05 13:30:14 +01:00
Svilen Markov b075607bac Make app icon bigger 2025-05-05 13:27:27 +01:00
Svilen Markov 56bdd2c9a3 Make repository widget links bigger on mobile 2025-05-05 13:15:51 +01:00
Svilen Markov 0f6e51ee5c Add new logo 2025-05-05 12:47:40 +01:00
Svilen Markov 1c5701fde5 Remove unused block 2025-05-05 11:04:40 +01:00
Svilen Markov 6c1c909c9d Make text non-selectable 2025-05-05 11:04:01 +01:00
Svilen Markov 26ab9c7b05 Auto detect favicon type and add fallback 2025-05-05 11:03:30 +01:00
Svilen Markov 176f14df70 Don't show background on hover 2025-05-05 10:58:22 +01:00
Svilen Markov 2d1e317c1f Reposition popover on scroll 2025-05-04 17:40:57 +01:00
Svilen Markov fcdea66a0f Hide popover on click 2025-05-04 17:40:49 +01:00
Svilen Markov 0e91958de5 Reduce text saturation of default light theme 2025-05-04 17:09:49 +01:00
Svilen Markov 6624b635df Increase contrast of default light theme 2025-05-04 17:05:20 +01:00
Svilen Markov 0612dc6f89 Use fmt.Errorf instead 2025-05-04 16:54:16 +01:00
Svilen Markov 0769f33653 Merge pull request #299 from hecht-a/theme_switcher
feat: theme switcher
2025-05-04 16:53:56 +01:00
Svilen Markov c2286f9a22 Update implementation 2025-05-04 16:49:20 +01:00
Svilen Markov a2d8410fec Merge remote-tracking branch 'upstream/dev' into theme_switcher 2025-04-29 19:03:41 +01:00
Svilen Markov a68805b55d Merge remote-tracking branch 'upstream/dev' into theme_switcher 2025-04-29 18:55:30 +01:00
Svilen Markov 8ca6879a1c Rename variable 2025-04-29 17:09:19 +01:00
Svilen Markov 1661f14adb Add timeout property 2025-04-29 17:08:36 +01:00
Svilen Markov 7d08eb312f Merge pull request #462 from charlesharries/rss-feed-conditional-requests
feat: Use conditional requests for RSS feeds
2025-04-29 10:42:28 +01:00
Svilen Markov 47340ed82a Merge branch 'dev' into rss-feed-conditional-requests 2025-04-29 10:39:19 +01:00
Svilen Markov 129441713b Refactor RSS widget 2025-04-29 10:37:46 +01:00
Svilen Markov a3e9510c1d Merge pull request #623 from mmshivesh/main
Minor design tweak, polishing up the polyline corners
2025-04-29 08:45:54 +01:00
Svilen Markov 328ba9a239 Merge branch 'main' into dev 2025-04-29 08:44:10 +01:00
Svilen Markov 3a3ff080ac Update returned err 2025-04-29 08:41:15 +01:00
Shivesh e2d3e1c04f Typed the wrong key- stroke-linecap instead of stroke-linejoin 2025-04-28 20:55:04 -04:00
Shivesh b3658bea09 Update markets.html
Ever so slightly improve the corner rounding of the polyline to make it more cohesive with the standard theming (slightly rounded corners etc.). Minor change, only visible at higher zoom levels.
2025-04-28 20:38:28 -04:00
Svilen Markov 1ad71f6974 Increase MaxIdleConnsPerHost 2025-04-28 20:01:12 +01:00
Svilen Markov 9dde306c0c Use limit query param in reddit widget 2025-04-28 18:49:10 +01:00
Svilen Markovands0ders d7a17aab01 Add reddit app auth #529
Co-authored-by: s0ders <39492740+s0ders@users.noreply.github.com>
2025-04-28 18:37:14 +01:00
Svilen Markov 65adf9b9c3 Add auto-invert option to icon fields 2025-04-28 16:27:08 +01:00
Svilen Markov aceb832645 Mention config schema in docs 2025-04-26 18:58:27 +01:00
Svilen Markov ec2f549295 Update property description 2025-04-26 18:56:25 +01:00
Svilen Markov 6e06c9c489 Bump results count to 20 #615 2025-04-26 18:12:25 +01:00
Svilen Markov 1862858c07 Merge pull request #453 from mike391/Add-support-configure-docker-containers-yaml
Feat: Added support for configuring Docker containers through glance.yaml instead of labels
2025-04-26 17:50:03 +01:00
Svilen Markov 306c28f203 Merge branch 'dev' into Add-support-configure-docker-containers-yaml 2025-04-26 17:44:34 +01:00
Svilen Markov bba2c5b20c Update label overrides implementation 2025-04-26 17:39:05 +01:00
Svilen Markov 18436e91e0 Docker containers: remote socket, category and running-only 2025-04-26 16:20:00 +01:00
Svilen Markov 77fb199cb3 Increase padding on mobile nav links 2025-04-26 16:20:00 +01:00
Svilen Markov 272bf1d2ad Merge pull request #594 from anxdpanic/error-responses
update error reporting for custom-api JSON validation
2025-04-24 09:06:05 +01:00
Svilen Markov ceebf34874 Use hex values & add theme_color in manifest.json
It looks like support for hsl color values within the manifest
is limited so we have to convert the colors to hex
2025-04-23 13:03:18 +01:00
Svilen Markov efd39e1f80 Merge pull request #602 from dvdpearson/dev
Dynamize manifest.json with configurable options
2025-04-23 12:25:09 +01:00
Svilen Markov 93c72f561b Also use app properties for apple-related elements 2025-04-23 12:18:46 +01:00
Svilen Markov 689e8f216c Rename function 2025-04-23 12:10:15 +01:00
Svilen Markov 4e4c3cfe64 Update dynamic manifest implementation 2025-04-23 12:09:33 +01:00
Svilen Markov e2112e0d83 Change HSL values to floats 2025-04-23 11:55:58 +01:00
Svilen Markov 44fca91089 Don't return if the err is a warning 2025-04-22 23:56:00 +01:00
Svilen Markov 4ae40cbd1e Merge branch 'main' into dev 2025-04-22 23:17:24 +01:00
Svilen Markov ea04aec0b4 Add target property to search widget
Create release / release (push) Has been cancelled
2025-04-22 23:07:07 +01:00
Svilen Markov b8e2717faf Add parseLocalTime function 2025-04-22 22:40:55 +01:00
David Pearson 333d40ed4f Dynamize manifest.json with configurable options 2025-04-20 10:00:17 -04:00
Svilen Markov cb3555f5ed Fix bug when making single request in job 2025-04-18 17:10:09 +01:00
anxdpanic 7a8f70db02 update error reporting for custom-api JSON validation
- show the request error on non-200 responses
2025-04-17 09:05:15 -04:00
Svilen Markov 1cf4f520f8 Merge pull request #578 from hitalos/dev
Support embed icons on bookmarks URLs
2025-04-15 21:58:24 +01:00
Svilen Markov e11ce5d3d0 Change the type of the field 2025-04-15 21:55:55 +01:00
Svilen Markov 502950709c Update sensors warning check 2025-04-15 04:08:05 +01:00
anxdpanic 2c3e14d84c [sensors] allow sensor readings when there are warnings 2025-04-15 04:07:50 +01:00
Svilen Markov 8e71afe158 Escape proxied request URL 2025-04-15 04:02:23 +01:00
Svilen Markov 44ea98fc41 Merge pull request #583 from anxdpanic/sensors
[sensors] allow sensor readings when there are warnings
2025-04-15 03:42:43 +01:00
Svilen Markov 5214fb9145 Update sensors warning check 2025-04-15 03:37:38 +01:00
anxdpanic 41e74ba9b5 [sensors] allow sensor readings when there are warnings 2025-04-14 09:05:18 -04:00
Svilen Markov 03b616622e Fix bug when making single request in job
Create release / release (push) Has been cancelled
2025-04-14 01:07:04 +01:00
Svilen Markov 50e1da01fe Add newline 2025-04-13 17:40:30 +01:00
Svilen Markov 0daa620b11 Merge branch 'main' into dev 2025-04-13 17:27:32 +01:00
Svilen Markov f8f50b26d8 Add mountpoint:info CLI command
Create release / release (push) Has been cancelled
2025-04-13 17:09:51 +01:00
Svilen Markov a3bc133bcb Maybe fix for missing mountpoint info 2025-04-13 16:52:17 +01:00
Svilen Markov 50dae22ff4 Add restart: unless-stopped 2025-04-13 16:15:59 +01:00
Svilen Markov 5a91154eab Document function 2025-04-13 16:08:04 +01:00
Ralph Ocdol d5b89d512a added unique to filter arrays with unique items in custom-api 2025-04-13 16:07:54 +01:00
Svilen Markov c4e4c62072 Merge pull request #580 from ralphocdol/unique-array-custom-api
added unique to filter arrays with unique items in custom-api
2025-04-13 16:04:16 +01:00
Svilen Markov 33b54ded4f Delete main.css 2025-04-13 16:01:54 +01:00
Svilen Markov 0d2f5a818b Merge branch 'dev' into unique-array-custom-api 2025-04-13 16:00:39 +01:00
Svilen Markov 8f217e6f01 Merge branch 'main' into dev 2025-04-13 15:58:10 +01:00
Svilen Markov 835d7767cb Merge remote-tracking branch 'upstream/main' into unique-array-custom-api 2025-04-13 15:54:52 +01:00
Svilen Markov 8892a39160 Document function 2025-04-13 15:52:16 +01:00
Ralph Ocdol db97871945 added unique to filter arrays with unique items in custom-api 2025-04-13 10:46:43 +08:00
Hítalo Silva 90254b92d2 Support embed icons on bookmarks URLs 2025-04-12 19:48:09 -03:00
Svilen Markov eeda2104a6 Only set title if attribute isn't set 2025-04-11 23:15:31 +01:00
Svilen Markov 97f43f88eb Only set title if attribute isn't set 2025-04-11 23:14:06 +01:00
Svilen Markov 793da48f5c Add rounded class 2025-04-11 22:35:40 +01:00
Svilen Markov 3177af9524 Add rounded class 2025-04-11 22:34:53 +01:00
Svilen Markov f99d22738c Add replaceMatches 2025-04-11 20:37:45 +01:00
Ralph Ocdol ea7124db52 feat: custom-api's replaceAll with regex support 2025-04-11 20:37:36 +01:00
Lucas L. b1247e5de6 Add filter on already seen links for RSS feeds 2025-04-11 20:34:47 +01:00
Svilen Markov 5ab962634e Fix sorting bug in twitch channels widget 2025-04-11 20:34:03 +01:00
Svilen Markov d99944dcff Avoid spinning up unnecessary goroutines for single data jobs 2025-04-11 20:33:24 +01:00
Svilen Markov 84f7286460 Make Get return decorated result
This may break existing widgets that rely on .Get returing a raw gjson.Result
2025-04-11 20:30:07 +01:00
Svilen Markov 920bcb06dc Print warnings when failing to get sensors 2025-04-11 19:34:49 +01:00
Svilen Markov 3679cc21ab Increase scale of everything on mobile
Create release / release (push) Has been cancelled
2025-04-10 00:44:55 +01:00
Svilen Markov 07e5626469 Add version & sensors:print cli commands 2025-04-10 00:39:08 +01:00
Svilen Markov 6bb6f87800 Fix diagnose command 2025-04-10 00:35:27 +01:00
Svilen Markov cb6b52ff4c Bump go version 2025-04-10 00:34:53 +01:00
Svilen Markov 03c377dff3 Update dependencies 2025-04-10 00:14:40 +01:00
Svilen Markov d9c934d3eb Merge pull request #494 from HtFilia/dev
Add filter on already seen links for RSS feeds
2025-04-09 17:15:11 +01:00
Svilen Markov 0bc31b0f31 Merge pull request #542 from ralphocdol/custom-api-replace-all-regex
Add replaceMatches
2025-04-09 17:07:43 +01:00
Svilen Markov 5a38c8dd2f Add replaceMatches 2025-04-09 17:05:18 +01:00
Svilen Markov 0e3f172032 Merge pull request #553 from Panonim/patch-1
Added Community Widgets Link to Readme.
2025-04-09 16:40:36 +01:00
Svilen Markov fd704419d9 Update readme links 2025-04-09 16:39:53 +01:00
Artur Flis 32ac72f592 Update README.md 2025-04-02 19:57:01 +02:00
Ralph Ocdol 15f20ffaeb feat: custom-api's replaceAll with regex support 2025-03-31 22:24:51 +08:00
Svilen Markov 9c77b87435 Merge pull request #486 from daot/main
Make clickable area for docker-containers widget items bigger
2025-03-30 14:57:16 +01:00
Svilen Markov d4d9e94f44 Make link hitbox take up full width 2025-03-30 14:53:59 +01:00
Svilen Markov 47cef2abf0 Merge pull request #499 from FranklyFuzzy/main
Added Dracula theme code and screenshot
2025-03-30 14:40:15 +01:00
Svilen Markov 0a5cdf380f Merge pull request #528 from diceroll123/patch-1
Fix typo
2025-03-30 14:39:39 +01:00
Svilen Markov 7f0e9b3289 Merge branch 'main' into dev 2025-03-29 18:01:15 +00:00
Svilen Markov 9da7967158 Update docs
Create release / release (push) Has been cancelled
2025-03-29 18:00:36 +00:00
Svilen Markov 964744a9ae Add offsetNow function 2025-03-29 17:57:43 +00:00
Svilen Markov 779304d035 Allow skipping JSON check and add JSONLines 2025-03-29 13:36:22 +00:00
Svilen Markov f15d7445bd Add concat function 2025-03-29 13:20:53 +00:00
Svilen Markov 26d68ba3fc Allow extension widget to specify title-url 2025-03-29 10:56:11 +00:00
Svilen Markov 9effff25df Increase z-index of mobile nav
This fixes the carousel gradient side
being above it since it also has z-index 10
2025-03-29 10:49:52 +00:00
Svilen Markov 958805a1fd Increase z-index of mobile nav
This fixes the carousel gradient side
being above it since it also has z-index 10
2025-03-29 10:49:37 +00:00
Svilen Markov dd74c173a5 More custom-api additions/tweaks
* Remove need to convert to int for math stuff
* Add `now` and `duration` functions
2025-03-28 23:34:49 +00:00
Svilen Markov bd020c93f5 Fix markets price precision 2025-03-28 14:11:17 +00:00
Steve C 75555f0426 Fix typo 2025-03-27 23:45:27 -04:00
Svilen Markov 4e6b14a467 Merge branch 'main' into dev 2025-03-26 19:46:16 +00:00
Svilen Markov 233de7fc37 Add new functions to custom-api widget
Create release / release (push) Has been cancelled
2025-03-26 19:44:35 +00:00
Svilen Markov c6e0230e5d Allow sending headers in extension widget 2025-03-26 17:59:41 +00:00
Svilen Markov ca2732668e Update docs 2025-03-26 17:58:52 +00:00
Svilen Markov 456b76ac96 Allow specifying method and request body 2025-03-26 17:58:35 +00:00
Svilen Markov a2c58bea82 Add parseRelativeTime function 2025-03-26 11:28:05 +00:00
Svilen Markov 7276b3d5ef Add allow-insecure to custom-api widget 2025-03-26 11:27:47 +00:00
Svilen Markov 1ef8315462 Only override extension url query if parameters property present 2025-03-26 11:27:12 +00:00
Svilen Markov 2f88ac419b Add parseTime to custom-api 2025-03-26 11:27:07 +00:00
Ralph OcdolandSvilen Markov cee7c85780 Add subrequests to custom-api (#385)
* feat: custom-api multiple API queries

* fix template check

* refactor

* Update implementation & docs

* Swap statement

* Update docs

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-26 11:26:16 +00:00
Svilen Markov b2c5205035 Add support for bool in query params fields 2025-03-26 11:25:47 +00:00
Svilen Markov 2be59c3eb6 Fix failing to parse empty response body in custom api widget 2025-03-26 11:25:34 +00:00
Svilen Markov 176fc896ae Also apply to extension widget 2025-03-26 11:24:43 +00:00
Svilen Markov a943555d86 Add note to docs 2025-03-26 11:24:27 +00:00
Svilen Markov 58bbfbbc42 Make query parameters field reusable 2025-03-26 11:24:15 +00:00
Ralph Ocdol 026b644630 feat: add parameters and array parameters support 2025-03-26 11:24:08 +00:00
FranklyFuzzy 1483b355af Update themes.md
Added Dracula theme with screenshot
2025-03-23 14:16:09 -04:00
FranklyFuzzy 6a5bb635bb Add files via upload 2025-03-23 14:14:54 -04:00
Lucas L. 18241136e3 Add filter on already seen links for RSS feeds 2025-03-22 19:59:00 +01:00
Svilen Markov 6fb6467b07 Fix sorting bug in twitch channels widget 2025-03-22 11:02:06 +00:00
Svilen Markov 83f0d16904 Increase scale on mobile 2025-03-21 23:40:23 +00:00
‮daot 0e0aca3844 Update docker-containers.html 2025-03-21 18:08:15 -05:00
Svilen Markov 97b52792ef Fix CSS issues on mobile due to order of declarations 2025-03-21 14:04:43 +00:00
Svilen Markov 0680c5ff9c Bump contrast of progress value 2025-03-21 13:50:33 +00:00
Svilen Markov f1f0158238 Add parseRelativeTime function 2025-03-20 23:49:09 +00:00
Svilen Markov bca3617116 Add allow-insecure to custom-api widget 2025-03-20 23:02:17 +00:00
Svilen Markov 55ae674e0b Add desktop-navigation-width property 2025-03-20 22:54:39 +00:00
Svilen Markov 46eb610d26 Fix typo 2025-03-20 22:54:39 +00:00
Svilen Markov bfc2e9c7fb Reduce contrast of progress bar values 2025-03-20 22:54:39 +00:00
Svilen Markov ffedd9eaf9 Merge pull request #480 from stripedew/dev
Add Kagi and Startpage as search engines
2025-03-20 19:37:08 +00:00
Svilen Markov 1512718d41 Update readme
Added "Common issues"
2025-03-20 19:27:29 +00:00
Svilen Markov e566dcd0d9 Make config variables matching stricter
This prevents strings such as ${whatever} from getting
confused for env variables and resulting in config errors
2025-03-20 16:57:56 +00:00
Svilen Markov 43b8f8f31b Split CSS into multiple files
This shouldn't result in anything breaking,
will require thorough testing
2025-03-20 16:57:55 +00:00
stripedew 768f700719 Update configuration.md 2025-03-20 12:02:33 +00:00
stripedew ab1faedadf Update widget-search.go 2025-03-20 12:01:26 +00:00
stripedew fa325bd519 Update configuration.md
Added Kagi
2025-03-20 11:53:59 +00:00
Svilen Markov 568876fc4f Merge pull request #476 from jpinz/dev
Add bing and perplexity as search engines
2025-03-19 20:00:54 +00:00
Julian d506604918 Add bing and perplexity as search engines 2025-03-19 16:25:10 +00:00
Svilen Markov 7c9f79f243 Merge pull request #466 from ralphocdol/dns-stats-pihole-default-title-url
make title-url of pihole and pihole-v6 default to /admin
2025-03-18 13:13:46 +00:00
Svilen Markov a77d0ce5c0 Move behavior within the widget init
Otherwise we create a global getWidgetTitleURL
function that sounds a little too generic and vague
2025-03-18 13:11:19 +00:00
Ralph Ocdol 55685e6854 make title-url of pihole and pihole-v6 default to /admin 2025-03-18 11:02:26 +08:00
Svilen Markov 4731b90899 Merge pull request #456 from slashformotion/slashformotion/refactor
refacto: remove duplicate
2025-03-17 20:49:10 +00:00
Charles Harries f36527995e feat: Use conditional requests for RSS feeds 2025-03-17 19:06:32 +00:00
Svilen Markov fd5cf98072 Refactor config variables and add new features
* Can now use Docker secrets
* Can now read files who's path is provided by an env var
2025-03-17 17:37:51 +00:00
slashformotion 844e32a11e refacto: remove duplicate 2025-03-17 16:26:43 +01:00
Svilen Markov 82cb0143f2 Only override extension url query if parameters property present 2025-03-17 14:16:47 +00:00
MikeC 51e70347e4 Add Documentation 2025-03-17 07:09:31 -04:00
MikeC 075bdfdc23 Add ability to convert docker container names to humanreadable names 2025-03-17 06:58:11 -04:00
MikeC e84edb3e30 Add support to configure docker containers in yaml 2025-03-17 06:53:08 -04:00
Svilen Markov 2bde4656ed Merge branch 'main' into dev 2025-03-17 03:01:06 +00:00
Svilen Markov 0b2cfc45c1 Docker containers accessibility improvements
Create release / release (push) Has been cancelled
2025-03-17 02:06:32 +00:00
Svilen Markov c8be41ce6c Reduce contrast of calendar dates 2025-03-17 00:45:23 +00:00
Svilen Markov ec2f710d06 Use constant for the prefix 2025-03-16 23:17:07 +00:00
Svilen Markov 71112173b9 Fix title link not opening in new tab 2025-03-16 23:15:19 +00:00
Svilen Markov 1615c20e66 Change custom api int64 types to int
Some of Go's native template functions return int and
having to juggle between int and int64 might get messy
so we'll try to stick to having a single int type
2025-03-16 01:32:35 +00:00
Svilen Markov fbcea12786 Add more info to logged message 2025-03-16 01:25:13 +00:00
Svilen Markov 047d13afd1 Fix summary triangle showing on Safari 2025-03-16 01:24:56 +00:00
Svilen Markov 4c1165533c Define service strings as consts to avoid forgetting to change them in all places
Create release / release (push) Has been cancelled
2025-03-15 19:41:08 +00:00
Svilen Markov 14bdcf71fe Remove debug code 2025-03-15 19:10:21 +00:00
Svilen Markov 1b3f022b2d Rename service to reduce ambiguity
Create release / release (push) Has been cancelled
2025-03-15 19:03:48 +00:00
Svilen Markov b6d93a7f09 Merge pull request #371 from KallanX/enhancement/add-support-pihole-v6
Enhancement/add support pihole v6
2025-03-15 18:45:22 +00:00
Svilen Markov 4b2438c298 Refactor DNS stats widget 2025-03-15 18:42:58 +00:00
Svilen Markov 3b79c8e09f Remove symbol-link-template 2025-03-15 10:27:39 +00:00
Svilen Markov d22ac6a7a4 Add parseTime to custom-api 2025-03-13 00:55:31 +00:00
Svilen Markov 2f50f5ef34 Merge branch 'main' into dev 2025-03-12 18:04:16 +00:00
Svilen Markov d7bbf2b8e2 Accessibility improvements
Create release / release (push) Has been cancelled
2025-03-12 18:01:35 +00:00
Svilen Markov b8df34309f Add note to docs about page width 2025-03-12 18:01:35 +00:00
Svilen Markov 278fa3c397 Add gap between domain and percent 2025-03-12 18:01:35 +00:00
Svilen Markov 6f48ee98e5 Uppercase currency before looking for matching symbol #365 2025-03-12 18:01:35 +00:00
Svilen Markov 14db59318c Avoid spinning up unnecessary goroutines for single data jobs 2025-03-12 10:35:54 +00:00
Svilen Markov f9b3deaff2 Simplify worker num with min 2025-03-12 10:35:54 +00:00
Ralph OcdolandSvilen Markov c265e42220 Add subrequests to custom-api (#385)
* feat: custom-api multiple API queries

* fix template check

* refactor

* Update implementation & docs

* Swap statement

* Update docs

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-12 10:30:29 +00:00
Svilen Markov c0bdf1551d Add support for bool in query params fields 2025-03-10 09:56:47 +00:00
Ralph OcdolandSvilen Markov e373eeeed3 fix: full width clickable link for monitor-site (#405)
* feat: full width clickable link for monitor-site

* refactor

* Use grow instead of width-100

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-10 09:49:38 +00:00
Svilen Markov 774b0c104b Merge pull request #399 from KadoBOT/patch-1
Update configuration.md
2025-03-08 09:46:37 +00:00
Ricardo Ambrogi fbd4d9a74e Update configuration.md
Correct `title` to `name`.

I'm unsure if `title` is also accepted, I haven't tested it. Please close this PR if this fix is not valid.
2025-03-07 09:07:10 +01:00
Svilen Markov 6c8859863a Add description property to bookmarks widget links 2025-03-02 00:09:28 +00:00
Svilen Markov 31ecd91f7c Fix failing to parse empty response body in custom api widget 2025-03-01 23:43:33 +00:00
Svilen Markov 474255c985 Tweak error message 2025-03-01 23:38:29 +00:00
Svilen Markov 652f9ceb5c Merge pull request #378 from ralphocdol/custom-api-array-parameters
feat: add parameters and array parameters support
2025-03-01 23:36:29 +00:00
Svilen Markov 49668d4ba9 Also apply to extension widget 2025-03-01 23:30:20 +00:00
Svilen Markov acddaf07db Add note to docs 2025-03-01 23:29:56 +00:00
Svilen Markov 8da26ab409 Make query parameters field reusable 2025-03-01 23:29:28 +00:00
Svilen Markov 3f2fefe4f7 Merge pull request #382 from legoraft/main
Added gruvbox theme
2025-03-01 22:44:22 +00:00
Legoraft fb8513bc9f Added gruvbox theme code and screenshot 2025-03-01 14:19:36 +01:00
Legoraft e4ec958edb Added gruvbox screenshot 2025-03-01 14:17:17 +01:00
Ralph Ocdol 948289a038 feat: add parameters and array parameters support 2025-02-28 08:48:07 +08:00
Keith Carichner Jr 78ad88a245 Merge pull request #1 from ralphocdol/fix-graph
Fix Graph and move SID to header
2025-02-27 10:33:20 -05:00
Ralph Ocdol 99660aeee8 Fix Graph and move SID to header 2025-02-27 23:02:10 +08:00
Svilen Markov ce293ed891 Prevent infinite config include recursion 2025-02-27 07:22:18 +00:00
Svilen Markov 2738613344 Improve error message when widget type not specified 2025-02-27 07:12:07 +00:00
Svilen Markov 5d12d934b8 Use new range syntax 2025-02-27 07:11:44 +00:00
Svilen Markov 19a89645a1 Add support for nested includes 2025-02-27 07:11:03 +00:00
Keith Carichner Jr 65a412eb59 Pushing latest. Add ability to fetch series data from new api. 2025-02-26 16:20:43 -05:00
Svilen Markov 9df9673e84 Add alternative include syntax
Also make it the new recommended way for doing includes
2025-02-25 02:25:01 +00:00
Keith Carichner Jr 2002ed1c9c Pushing latest changes. 2025-02-24 13:52:27 -05:00
Svilen Markov 9c98c6d0c4 Merge branch 'main' into dev 2025-02-22 13:33:17 +00:00
Svilen Markov 488a1f6070 Update link to point to repo instead of pkg.go.dev 2025-02-22 13:32:50 +00:00
Svilen Markov 4d6600b0a3 Merge pull request #367 from ejsadiarin/dev
feat(monitor): add basic-auth feature for protected sites
2025-02-22 13:30:09 +00:00
Svilen Markov dac0d15e78 Update implementation 2025-02-22 13:29:00 +00:00
Svilen Markov 1e12d937aa Merge pull request #372 from DavisYe/main
Correct document
2025-02-22 12:58:56 +00:00
Keith Carichner Jr e643a44b59 Pushing latest fixes to handle the two different JSON responses. 2025-02-20 14:54:11 -05:00
DavisYe 7319870289 Correct the docker compose environment format of GITHUB_TOKEN in the configuration document. 2025-02-20 21:30:34 +08:00
Keith Carichner Jr 72c1ebf66d Minor README adjustment. 2025-02-19 23:23:22 -05:00
Keith Carichner Jr 008c4a3ab7 Removing SID check func. 2025-02-19 23:04:45 -05:00
Keith Carichner Jr 38d3d11571 Attempting to add support for Pi-hole v6. 2025-02-19 17:30:14 -05:00
ejsadiarin 5b45751c67 docs(monitor): add documentation for basic-auth feature 2025-02-19 17:40:56 +08:00
ejsadiarin c00d937f4c feat(monitor): add basic-auth feature for protected sites
this closes [issue #316](https://github.com/glanceapp/glance/issues/316)

Furthermore, this could be expanded to also pass the configured basic
auth credentials to the request when the user clicks on the specific
monitor widget
2025-02-19 17:28:13 +08:00
Svilen Markov d4565acfe7 Markets widget rate limit fix
Create release / release (push) Has been cancelled
2025-02-19 02:25:07 +00:00
Svilen Markov 16129c53bd Merge pull request #358 from rubiojr/rubiojr/reload-on-rename2
Auto-reload config file on RENAME
2025-02-19 01:25:08 +00:00
Svilen Markov cbf1961510 Don't try to get sensor info on openbsd 2025-02-17 23:48:16 +00:00
Svilen Markov c76a4d4be7 Increase docker containers widget timeout 2025-02-17 23:45:57 +00:00
Svilen Markov c33fe45d4c Merge branch 'main' into dev 2025-02-17 23:27:42 +00:00
Svilen Markov e355c643f4 Merge pull request #339 from KevinFumbles/dev
Added Technitium Service Option to DNS-Stats Widget
2025-02-17 23:20:54 +00:00
Svilen Markov fcccb7eb38 Update error message 2025-02-17 23:20:38 +00:00
Svilen Markov f9209406fb Reduce duplication of constants 2025-02-17 23:18:27 +00:00
Svilen Markov facbf6f529 Remove mention of env variable syntax 2025-02-17 23:08:36 +00:00
Svilen Markov 27af0400c0 Tweak impl for handling config renames 2025-02-17 22:28:10 +00:00
Sergio Rubio 76a80ff034 Add clarifying comment 2025-02-17 19:17:49 +01:00
Sergio Rubio f7f333ad52 Auto-reload config file on RENAME
Some editors (like Vim), create a temp file when saving, then replace
the file being edited with the temp file. This causes the FS notify
event to be RENAME, not WRITE, so auto-reload misses this.

In addition to that, the file is removed from the watcher and the
auto-reload functionality stops working entirely after the first RENAME.

https://github.com/fsnotify/fsnotify/issues/255 describes this.
2025-02-17 19:08:54 +01:00
Svilen Markov 0ce45e32aa Update dashboard icons repo
Create release / release (push) Has been cancelled
2025-02-15 14:59:20 +00:00
Svilen Markov abeb11c8a6 Add hide-mountpoints-by-default prop for server-stats 2025-02-15 14:43:25 +00:00
Svilen Markov e01af4adec Delay changing popover display
Previously would make the popover visible and then reposition
it on the next frame in order to avoid getting called recursively
due to the observer, however this causes the scrollbar to appear
if it wasn't already visible for a single frame which is janky.

This change fixes that.
2025-02-15 13:53:13 +00:00
Svilen Markov 3043a0bd15 Rework getting host info (#340) 2025-02-15 13:46:58 +00:00
Kevin 94806ed45d Added blocked domains count for Technitium 2025-02-12 16:05:38 -05:00
Kevin baee94ed1d Added configuration documentation for Technitium dns-stats service 2025-02-12 15:58:21 -05:00
Kevin 0c8358beaa Added Technitium as a valid service for dns-stats widget 2025-02-12 15:58:05 -05:00
Svilen Markov 232cab01f8 Fix typo 2025-02-10 23:15:12 +00:00
Svilen Markov b301953249 Update docs 2025-02-10 20:38:50 +00:00
Svilen Markov bb9cb03c8a Fix index out of range
Create release / release (push) Has been cancelled
2025-02-10 11:22:48 +00:00
Svilen Markov d8a4d39849 Merge pull request #330 from glanceapp/release/v0.7.0
Create release / release (push) Has been cancelled
Release/v0.7.0
2025-02-09 19:07:48 +00:00
Svilen Markov 0d6966726e Update pull request template 2025-02-09 18:45:23 +00:00
Svilen Markov bb26725c67 Include pkg dir when building docker image 2025-02-09 18:21:23 +00:00
Svilen Markov 103fe5718b Tidy mod 2025-02-09 18:15:50 +00:00
Svilen Markov a0d1bf1788 Bump versions 2025-02-09 18:04:25 +00:00
Svilen Markov 37f35281b4 Add server-stats widget 2025-02-09 17:52:22 +00:00
Svilen Markov 306fb3cb33 Add new calendar and deprecate old 2025-02-09 17:47:20 +00:00
Svilen Markov 24a6107171 Update config docs 2025-02-09 17:33:20 +00:00
Svilen Markov e495fbfd99 Remove comment 2025-02-09 17:33:07 +00:00
Svilen Markov da6fbc4995 Update custom-api doc 2025-02-09 17:32:55 +00:00
Svilen Markov 8d673e1229 Update readme 2025-02-09 17:32:43 +00:00
Svilen Markov d8c7d02f5e Add custom-api examples 2025-02-09 05:33:08 +00:00
Svilen Markov 8331c4b305 Add Exists function 2025-02-09 05:32:30 +00:00
Svilen Markov 5a06f4788b Return NaN instead of 0 when dividing by 0 2025-02-09 05:32:05 +00:00
Svilen Markov 827171e206 Add upgrade guide 2025-02-09 03:04:29 +00:00
Svilen Markov c3f3fa9876 Update readme 2025-02-09 00:40:19 +00:00
Svilen Markov 825ef84da5 Add comment 2025-02-09 00:39:51 +00:00
Svilen Markov 4445d46f61 Add example glance.yml to docs 2025-02-09 00:39:36 +00:00
Svilen Markov c583057d45 Allow specifying playlists separately 2025-02-09 00:31:53 +00:00
Svilen Markov 982f3ee2a1 Add issue templates 2025-02-07 11:11:58 +00:00
Svilen Markov cd1e4e9f91 Update update warn link 2025-02-07 11:06:44 +00:00
Svilen Markov 33f8e6d144 Update notice page 2025-02-07 11:06:20 +00:00
Svilen Markov af70cc83ba Add preserve-order and limit props to RSS widget 2025-02-07 11:05:53 +00:00
Svilen Markov 8b731fd9e5 Invert colors in docs image 2025-02-07 11:01:46 +00:00
Svilen Markov a0c697b982 Only ignore glance*.yml in root dir 2025-02-07 11:00:58 +00:00
Svilen Markov 21a6fe407b Remove println 2025-02-04 03:55:41 +00:00
Svilen Markov 1c1a7b3e27 Change label from title to name 2025-02-02 13:02:59 +00:00
Svilen Markov 61fd5481cc Update custom API 2025-02-02 13:02:46 +00:00
Svilen Markov d400b27545 Fix observer console errors 2025-01-22 16:59:10 +00:00
Svilen Markov ec9d455d18 Merge pull request #307 from Mystically11/add-prereleases-to-releases
feat: Add option to include GitHub repository pre-releases in releases widget
2025-01-17 20:18:47 +00:00
Svilen Markov 8a75f303bb Remove extra whitespace 2025-01-17 20:16:55 +00:00
Svilen Markov 0ce706e02e Update releases widget 2025-01-17 20:14:07 +00:00
Svilen Markov 3076593021 Merge remote-tracking branch 'upstream/release/v0.7.0' into add-prereleases-to-releases 2025-01-17 19:35:46 +00:00
Svilen Markov a83389bb7d Merge pull request #314 from JeckDev/release/v0.7.0
feat: add alternate link (error-url) for monitor widget sites
2025-01-17 19:10:41 +00:00
Svilen Markov ee1cde8a04 Update monitor widget 2025-01-17 19:09:49 +00:00
HECHT Axel 62e9c32082 feat: theme switcher 2025-01-15 12:19:50 +01:00
Svilen Markov a9efe88461 Revert points abbreviation 2025-01-15 07:49:08 +00:00
Jeck c8200f2972 Added error-url parameter to monitor sites 2025-01-14 21:32:04 -08:00
Svilen Markov 80b43d3e0c Further simplify points abbreviation implementation 2025-01-14 11:58:26 +00:00
Svilen Markov a7e235441b Simplify points abbreviation implementation 2025-01-14 11:55:52 +00:00
Svilen Markov bb80637e72 Only hide comment count if there's a target URL 2025-01-14 11:35:02 +00:00
Svilen Markov 8fb5de4e87 Remove whitespace in template 2025-01-14 10:48:35 +00:00
Svilen Markov 1c03f0a07a Update forum post templates
* Use approx number for points & comments
* Hide comments when limited on horizontal space
* Abbreviate "points" to "pts" when limited on horizontal space
* Don't let domain wrap
2025-01-14 10:41:46 +00:00
Svilen Markov b4ac96ccaf Remove whitespace in template & increase popover max width 2025-01-13 10:47:55 +00:00
Svilen Markov 5723fbdea5 Add markets widget link templates & automatic name 2025-01-12 04:21:47 +00:00
Svilen Markov 260bc15577 Remove reddit widget title forward slash prefix 2025-01-11 22:31:45 +00:00
Svilen Markov 59bfe3e835 Add vertical-list style for videos widget 2025-01-11 19:48:06 +00:00
Svilen Markov 086fac4120 Fix monitor widget showing incorrect status icon 2025-01-11 17:59:07 +00:00
Mystically 46bc36ad4a oops 2025-01-09 21:20:11 +01:00
Mystically ed477e8cc6 add option to include github prereleases 2025-01-09 21:19:13 +01:00
Svilen Markov 8b878149d4 Update docs 2025-01-09 17:17:56 +00:00
Svilen Markov db512419ea Add new text color class 2025-01-07 15:34:58 +00:00
Svilen Markov 491bc65a38 Add target property on bookmarks widget 2025-01-07 10:45:54 +00:00
Svilen Markov 94ec286de6 Add codeblock highlighting 2025-01-06 21:28:53 +00:00
Svilen Markov 108c83588c Allow using a standard HTTP proxy in reddit widget 2025-01-06 20:40:46 +00:00
Svilen Markov abbb4950a5 Fix HTML entities in RSS item titles 2025-01-04 07:49:29 +00:00
Svilen Markov 0fe0b34898 Merge pull request #298 from hecht-a/add_search_placeholder
feat: add possibility to set search input placeholder
2025-01-04 03:47:51 +00:00
HECHT Axel 8ad7d4be0a feat: add possibility to set search input placeholder 2025-01-03 19:36:43 +01:00
Svilen Markov ac7f3805d4 Add custom unmarshalling for pihole queries stats
+ hide-graph and hide-top-domains options for DNS stats widget
2024-12-28 14:17:48 +00:00
Svilen Markov e524dd111e Merge pull request #286 from DallasHoff/mobile-style-fixes-0.7.0
Mobile style fixes
2024-12-26 17:04:21 +00:00
Svilen Markov 4a14709f13 Tweak iOS fixes 2024-12-26 17:02:15 +00:00
Svilen Markov 85b8ee493e Update dependencies
Create release / release (push) Has been cancelled
2024-12-26 13:24:40 +00:00
Svilen Markov e546488aeb Fix broken reddit video & nsfw post thumbnails 2024-12-26 13:20:03 +00:00
Svilen Markov 0702e0abd3 Fix broken reddit video thumbnail URLs 2024-12-26 13:16:38 +00:00
Dallas Hoffman 29318005b2 Mobile style fixes 2024-12-24 14:42:28 -05:00
Svilen Markov b4b61c94d7 Merge branch 'main' into release/v0.7.0 2024-12-22 14:34:11 +00:00
Svilen Markov f18620d890 Add note about extension URL query parameters 2024-12-22 14:32:56 +00:00
Svilen Markov eb86c3fb79 Allow setting same-tab and hide-arrow on bookmark groups 2024-12-22 14:22:11 +00:00
Svilen Markov f89fc2ee1d Preserve formatting of non-html extension widget content 2024-12-22 13:44:41 +00:00
Svilen Markov 495eaa0a37 Add Widget-Content-Frameless option to extension widget 2024-12-22 13:31:26 +00:00
Svilen Markov b44b7b80ff Don't use sprintf for relative time attribute 2024-12-20 11:41:48 +00:00
Svilen Markov c8ff5362a3 Update dependencies 2024-12-19 23:05:47 +00:00
Svilen Markov d575a0a29f Remove console.log 2024-12-18 12:47:30 +00:00
Svilen Markov 2e1134fdfb Properly handle future timestamps for relative time 2024-12-18 11:30:17 +00:00
Svilen Markov ab6ae15836 Add automatic native titles to all truncateable elements 2024-12-17 23:34:32 +00:00
Svilen Markov a4840c7365 Remove unnecessary variables 2024-12-17 00:30:34 +00:00
Svilen Markov dbcc13a5cf Allow inserting env variables anywhere in the config 2024-12-16 23:59:25 +00:00
Svilen Markov 8d2639b349 Allow RSS feed URLs to be specified by env variables 2024-12-16 14:08:47 +00:00
Svilen Markov 889b034a79 Rename struct 2024-12-16 14:07:37 +00:00
Svilen Markov e49145023f Don't escape user-defined URLs 2024-12-16 14:05:52 +00:00
Svilen Markov ae1fb05607 Better convention for safe template types 2024-12-16 13:58:58 +00:00
Svilen Markov 80a957bc5b Change extension user-defined title to take precedence 2024-12-15 15:04:33 +00:00
Svilen Markov 24f93d8258 Merge pull request #275 from glanceapp/release/v0.6.3
Create release / release (push) Has been cancelled
Release/v0.6.3
2024-12-11 18:56:48 +00:00
Svilen Markov 01016e08d2 Merge branch 'main' into release/v0.6.3 2024-12-11 18:53:40 +00:00
Svilen Markov 949fde1517 Fix edge case in weather widget graph 2024-12-11 18:50:34 +00:00
Svilen Markov 804cf9916b Change simple icons provider and always use latest version 2024-12-11 18:47:30 +00:00
Svilen Markov ef24680baf Fix overlap issues in weather and dns-stats widgets 2024-12-11 18:38:09 +00:00
Svilen Markov d4b1d240b9 Fix releasing page mutex during template execution 2024-12-08 14:55:53 +00:00
Svilen Markov 448c795982 Properly close code block in docs 2024-12-07 16:47:58 +00:00
Svilen Markov 7f667e2d1c Allow using env variables in monitor and bookmarks widget URLs 2024-12-07 16:40:09 +00:00
Svilen Markov a1afdb0c63 Allow digits in env variable names 2024-12-07 16:17:09 +00:00
Svilen Markov ee1d7599d5 Add note to preconfigured pages doc 2024-12-07 09:28:36 +00:00
Svilen Markov 8adad18198 Move variable definition outside of closure 2024-12-07 09:20:01 +00:00
Svilen Markov 49a0758aae Fix trying to load incorrect thumbnail if post marked nsfw 2024-12-07 08:57:51 +00:00
Svilen Markovandvishalkadam47 e3bdc73013 Allow specifying playlist in videos widget
Co-authored-by: vishalkadam47 <vishal@jeevops.com>
2024-12-06 10:54:14 +00:00
Svilen Markov 1922e1e895 Increase RSS widget workers 2024-12-06 10:54:14 +00:00
Svilen Markov 7ee8e7c8d2 Merge pull request #242 from CremaLuca/main
feat: HEALTHCHECK in Dockerfile
2024-12-05 13:33:01 +00:00
Svilen Markov e5106c0704 Merge branch 'release/v0.7.0' into main 2024-12-05 13:32:16 +00:00
Svilen Markov 80331aa217 Also add healthcheck to the other Dockerfile 2024-12-05 13:31:25 +00:00
Svilen Markov 84537e6884 Update status icons 2024-12-04 08:19:24 +00:00
Svilen Markov f7062115bc Add support for selfhst icons 2024-12-04 08:11:18 +00:00
Svilen Markov 24f9f20eb7 Reduce list gap 2024-12-03 22:11:51 +00:00
Svilen Markov 217c6f2ed0 Remove container created time 2024-12-03 20:13:23 +00:00
Svilen Markov 82490ef5dd Merge pull request #261 from baranovskis/release/v0.7.0
Docker widget
2024-12-03 19:10:15 +00:00
Svilen Markov fcda017c39 Update docker containers widget 2024-12-03 19:04:43 +00:00
Svilen Markov 0c36925783 Fix off by 1px popover triangle 2024-12-03 16:52:28 +00:00
Svilen Markov fcb67e62c5 Change widget error header icon to SVG 2024-12-03 16:51:33 +00:00
Svilen Markov 0651a2886e Merge latest changes 2024-12-03 13:59:20 +00:00
Svilen Markov d876f4cb39 Add allow-insecure for dns-stats widget 2024-11-30 14:24:56 +00:00
Svilen Markov b5259d1a98 Rename variables & interface 2024-11-30 14:24:19 +00:00
Svilen Markov ffe053ffc5 Even more config watcher fixes 2024-11-30 12:54:19 +00:00
Svilen Markov 1785af4749 More config watcher fixes 2024-11-30 12:37:56 +00:00
Svilen Markov 98b4b7330e Further fixes for config file watcher 2024-11-30 12:23:57 +00:00
Svilen Markov f68e5ae9ef Optimize cache control header assignment in file server 2024-11-30 11:16:07 +00:00
Svilen Markov b3e73ce86a Move log outside of func 2024-11-30 10:59:58 +00:00
Svilen Markov 02cbb5f812 Fix CS 2024-11-30 10:48:33 +00:00
Svilen Markov 1d9ae72c81 Rename variables 2024-11-29 22:58:05 +00:00
Svilen Markov d6470ae814 Somewhat working fix for config watcher 2024-11-29 21:45:50 +00:00
Svilen Markov a816d1a913 Update error messages 2024-11-29 21:12:10 +00:00
Svilen Markov 2c03316f86 Update error 2024-11-29 21:06:58 +00:00
Svilen Markov d19214a390 Update error messages 2024-11-29 20:52:09 +00:00
Svilen Markov 03035d1a2d Move template vars 2024-11-29 20:45:33 +00:00
Svilen Markov 6886716e67 Add comment 2024-11-29 18:28:10 +00:00
Svilen Markov 6165308c23 Fix random overlapping issues after browser update... wtf? 2024-11-29 17:34:25 +00:00
Svilen Markov 77a9469ff8 Add console message about new config location 2024-11-29 17:11:07 +00:00
Svilen Markov 2dce9b4c48 Listen on all interfaces 2024-11-29 17:03:52 +00:00
Svilen Markov 74e05763f7 Re-add tzdata 2024-11-29 16:45:18 +00:00
Svilen Markov a4185fde07 Rename file 2024-11-29 16:41:15 +00:00
Svilen Markov ebb519e6d8 Capitalize console messages 2024-11-29 16:38:16 +00:00
Svilen Markov 90fbba600f Restructure & refactor codebase 2024-11-29 16:34:15 +00:00
Svilen Markov 4bd4921131 Merge pull request #267 from xendke/calendar-start-day
Optionally start calendar weeks on Sunday
2024-11-26 17:02:26 +00:00
Juan Xavier Gomez 44ee813c6f simplify cal weekday index 2024-11-25 15:49:28 -05:00
Juan Xavier Gomez 3e467c5021 optionally start calendar weeks on sunday 2024-11-25 13:04:52 -05:00
Svilen Markov 2b0dd3ab99 Refactor 1/2 + new stuff
* Refactor CLI
* Add config:print command
* Add diagnose command
* Allow including other files in config
* Watch for file changes and automatically restart server
2024-11-24 15:39:14 +00:00
Andrejs Baranovskis 2618346a32 Rewrote docker client to reduce dependencies ♻️ 2024-11-20 17:03:37 +01:00
Andrejs Baranovskis eacbb14279 Added docker widget with documentation 2024-11-19 17:58:55 +01:00
Andrejs Baranovskis c8570d07ef Added debugging Dockerfile 2024-11-19 17:31:42 +01:00
Svilen Markov 7e345dd1f9 Add comment 2024-11-17 10:18:11 +00:00
Svilen Markov afe6ad6bfc Don't focus new tab on middle click
This is more in line with how the browser behaves on middle click
2024-11-17 10:06:54 +00:00
Svilen Markov af4c1e8514 Prevent default on middle click 2024-11-17 09:57:46 +00:00
Svilen Markov d4fecd1dd8 Fix middle mouse click 2024-11-17 09:49:56 +00:00
Svilen Markov 79779eb721 Restore title-url functionality on group titles 2024-11-17 09:36:46 +00:00
Svilen Markov 6af666b58c Reduce margin 2024-11-16 15:00:05 +00:00
Svilen Markov 42872136a0 Reduce gap 2024-11-16 11:52:09 +00:00
Svilen Markov aca7b2ac5f Reduce status icon size 2024-11-16 11:45:23 +00:00
Svilen Markov a646f19b3f Make monitor widget compact style title size dynamic 2024-11-16 11:25:29 +00:00
Svilen Markov 9c5298aebf Add compact style for monitor widget 2024-11-16 11:10:46 +00:00
Svilen Markov c6b07852fe Clear search input on submission 2024-11-16 08:29:58 +00:00
Svilen Markov 40d3053df9 Merge pull request #249 from ralphocdol/default-expand-mobile-navigation
Default expand mobile navigation
2024-11-16 07:21:25 +00:00
Svilen Markov 5e576a58e9 Merge branch 'release/v0.7.0' into default-expand-mobile-navigation 2024-11-16 07:20:49 +00:00
Svilen Markov 4d63b5dda1 Update docs 2024-11-16 07:17:06 +00:00
Svilen Markov c1efd3f68b Fix formatting 2024-11-16 07:15:26 +00:00
Svilen Markov 2841cc67e8 Merge pull request #255 from 2q2code/main
Add Dashboard Icons support
2024-11-16 07:00:00 +00:00
Svilen Markov becf34b0d9 Merge branch 'release/v0.7.0' into main 2024-11-16 06:49:11 +00:00
Svilen Markov 1bba915aef Update docs 2024-11-16 06:37:09 +00:00
Svilen Markov f7239137d6 Update icons implementation to use custom type 2024-11-16 06:37:05 +00:00
Doc Em d656986413 Add DashboardIcon (PNG) Support
- Refactored to make better naming sense
- Added capability to select between the PNG and SVG offerings of DashboardIcons
2024-11-14 20:06:54 +11:00
2Q2C0DE d0c4e9d846 Add Dashboard Icons prefix support
- defined new type IconSource and constants, presently supporting:
  - LocalFile
  - SimpleIcon
  - DashboardIcon
- added new field to bookmarks and monitors to hold IconSource
- adjusted IsSimpleIcon to get truthiness from IconSource field
- generalised `toSimpleIconIfPrefixed` into `toRemoteResourceIconIfPrefixed`,
  adding support for `walkxcode`'s dashboard icons via CDN (svg)
2024-11-14 18:30:41 +11:00
Svilen Markov bacb607d90 Allow using env variables in custom API URL 2024-11-03 04:46:24 +00:00
Svilen Markov 3914e24b3d Update twitch channel stream preview popover 2024-11-01 22:57:34 +00:00
Svilen Markov 4582be1da5 Allow changing horizontal offset of popover 2024-11-01 22:57:06 +00:00
Svilen Markov c3c7f8b14f Allow changing max columns on split columns widget 2024-10-31 18:23:56 +00:00
Svilen Markov 863d4f117b Merge pull request #251 from ehaughee/market-name-hovertext
Add title prop to Market Name for full text of truncated names on hover
2024-10-30 23:57:11 +00:00
Eric Haughee 35038ed56b Add title prop to Market Name for full text of truncated names on hover 2024-10-30 15:25:19 -07:00
Svilen Markov c4149f5d94 Merge pull request #248 from oliver-mitchell/fix-non-whole-hour-timezone-display-on-clock
fix: non-whole-hour timezones now correctly shown on Clock widget
2024-10-30 15:48:24 +00:00
Svilen Markov de2db71e54 Simplify clock zone diff text and add title for more details 2024-10-30 15:30:34 +00:00
Svilen Markov 2e3ed896cc Give clock time min width to avoid inconsistent layout for 12 hour format 2024-10-30 15:25:30 +00:00
Ralph Ocdol f6f352e9e0 fix: indention 2024-10-29 18:15:28 +08:00
Ralph Ocdol 33dede8abc feat: added auto expand mobile page navigation 2024-10-29 18:13:24 +08:00
Oliver Mitchell 1e2e66ecf7 fix: non-whole-hour timezones now correctly shown on Clock
Timezones that used non-whole-hour definitions (ie. Australia/ACDT) not correctly shown on the Clock widget. This fix changes how the difference is calculated to account for both minutes, and hours.

Due to the change, the text shown for relative timezone differences also had to be changed.
2024-10-29 00:00:37 +10:30
Svilen Markov 6e5140d859 Allow specifying headers in RSS feeds 2024-10-21 23:49:09 +01:00
Svilen Markov 84a7f90129 Add custom API widget 2024-10-21 23:27:25 +01:00
Luca Crema aff02c2def Added HEALTHCHECK to Dockerfile 2024-10-18 14:07:24 +02:00
Svilen Markov 2dd5b29303 Remove unused class 2024-10-18 11:10:00 +01:00
Svilen Markov bc8f17393a Fix simple icons URL 2024-10-18 10:54:34 +01:00
Svilen Markov 72f78f1c8d Merge pull request #240 from cmeadowstech/alt-status-codes
Add alternative status code option to monitor widget
2024-10-18 10:53:18 +01:00
Svilen Markov 201b4ca1e8 Use AltStatusCodes for HasFailing check 2024-10-18 10:51:46 +01:00
Cody Meadows 04811a0658 Merge remote-tracking branch 'upstream/release/v0.7.0' into alt-status-codes
Update to current release
2024-10-17 22:01:56 +00:00
Cody Meadows ee94d6aa89 Add alternative status code prameter 2024-10-17 21:58:18 +00:00
Svilen Markov 3382cd6208 Show twitch stream preview and title on avatar hover 2024-10-17 21:53:07 +01:00
Svilen Markov 56f9ec1d10 Include title in twitch channel data 2024-10-17 21:37:32 +01:00
Svilen Markov 1b42312d02 Fix edge case in popover 2024-10-17 21:36:41 +01:00
Svilen Markov 8a41a9f506 Improve formatting of request body 2024-10-17 21:36:22 +01:00
Svilen Markov 18bb2d7501 Change simple icons provider 2024-10-16 10:58:09 +01:00
Svilen Markov 6f8e576c9b Add fallback-content-type property 2024-10-16 10:52:57 +01:00
Svilen Markov e5bb102ab1 Add split-column widget 2024-10-15 18:05:29 +01:00
Svilen Markov 13700fe2b2 Change masonry options source 2024-10-15 16:04:22 +01:00
Svilen Markov 3cfbe65855 Make the first full column the primary on mobile rather than the last 2024-10-15 15:58:30 +01:00
Svilen Markov ab0b11cc92 Add masonry layout functionality 2024-10-13 18:33:26 +01:00
Svilen Markov 17c8071de9 Extract group widget functionality into generic widget container 2024-10-13 18:30:47 +01:00
Svilen Markov 1fe7f61ec8 Change environment variables behavior
* Can now be inserted anywhere in the string
* Can now insert multiple environment variables in a single string
* Can now be escaped if prefixed with \
2024-10-13 17:12:59 +01:00
Svilen Markov ea3b8124fc Remove unused class 2024-10-13 17:08:52 +01:00
Svilen Markov c41cfadb19 Merge pull request #231 from ehaughee/markets-sort-by-change
Add Markets sort-by: change to sort by percent change
2024-10-04 11:09:59 +01:00
Eric Haughee e434fe0847 Add Markets sort-by: change to sort by percent change 2024-10-03 11:26:33 -07:00
Svilen Markov 8a8aaa752e Fix edge case in weather widget
If the temperature for the entire day is the same the range is 0, divide by 0 no bueno
2024-10-02 10:40:25 +01:00
Svilen Markov 4420d1df2c Bump versions & update dependencies 2024-10-01 15:47:56 +01:00
Svilen Markov d90d39933a Merge pull request #228 from Dakhtara/patch-1
Fix typo on configuration for search autofocus property description
2024-10-01 15:28:42 +01:00
Anthony 7e38ab624a Update configuration.md 2024-10-01 08:59:11 +02:00
Svilen Markov 9a36187333 Add /api/healthz endpoint
Create release / release (push) Has been cancelled
2024-09-30 00:42:19 +01:00
Svilen Markov b0c4d70628 Fix bug with collapsible grids inside of group widget
Also move utils to new file
2024-09-30 00:42:19 +01:00
Svilen Markov d5fa6424c1 Fix bookmark icons shrinking when text wraps 2024-09-30 00:42:19 +01:00
Svilen Markov 07fe5b3cb1 Merge pull request #224 from SimJunYou/piholePrivacy
DNS Stats widget - fix Pihole privacy edge case
2024-09-30 00:31:00 +01:00
Svilen Markov 672547cd07 Switch to using custom type for pihole's top blocked domains 2024-09-30 00:24:44 +01:00
SimJunYou 28167403a4 Cover edge case when Pihole's privacy settings are enabled 2024-09-19 17:43:43 +08:00
Svilen Markov eaf08d42dd Merge pull request #220 from paricbat/main
Add important details about the server.base-url property
2024-09-16 08:35:33 +01:00
Veronika Bušová bae95a5e07 Add important details about the server.base-url property 2024-09-15 00:29:46 +02:00
Svilen Markov db1ed9e257 Fix & refactor adguard stats
Create release / release (push) Has been cancelled
2024-09-10 05:38:14 +01:00
Svilen Markov adef35049f Add warning when pihole doesn't return expected data points 2024-09-10 05:38:14 +01:00
Svilen Markov bd9abf2e52 Fix mobile nav links wrapping 2024-09-10 05:38:14 +01:00
Svilen Markov aa74c00fd4 Merge pull request #210 from micash545/main
releases: Add support for Codeberg
2024-09-08 19:36:20 +01:00
Svilen Markov a68a907fa7 Update SVG 2024-09-08 19:35:01 +01:00
Svilen Markov 1a05d6d03a Don't export function 2024-09-08 19:34:43 +01:00
micash 60f4183057 add codeberg releases 2024-09-08 16:59:24 +02:00
Svilen Markov 04c3afc850 Document center-vertically property 2024-09-08 06:25:06 +01:00
229 changed files with 16429 additions and 6522 deletions
+1
View File
@@ -5,6 +5,7 @@
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly) # Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
!/build/ !/build/
!/internal/ !/internal/
!/pkg/
!/go.mod !/go.mod
!/go.sum !/go.sum
!main.go !main.go
+1
View File
@@ -1 +1,2 @@
github: [glanceapp] github: [glanceapp]
patreon: glanceapp
+37
View File
@@ -0,0 +1,37 @@
name: Bug report
description: Let us know if something isn't working as expected
labels: ["bug report"]
body:
- type: markdown
attributes:
value: |
> [!NOTE]
>
> Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
If you're unsure whether you're experiencing a bug or not, consider using the [Discussions](https://github.com/glanceapp/glance/discussions) or [Discord](https://discord.com/invite/7KQ7Xa9kJd) to ask for help.
Please include only the information you think is relevant to the bug:
* How did you install Glance? (Docker container, manual binary install, etc)
* Which version of Glance are you using?
* Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
* Include any relevant logs or screenshots if applicable
* Is the issue specific to a certain browser or OS?
* Steps to reliably reproduce the issue
* Are you hosting Glance on a VPS?
* Anything else you think might be relevant
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
- type: textarea
id: description
validations:
required: true
attributes:
label: Description
- type: markdown
attributes:
value: |
Thank you for taking the time to submit a bug report.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discussions
url: https://github.com/glanceapp/glance/discussions
about: For help, feedback, guides, resources and more
- name: Discord
url: https://discord.com/invite/7KQ7Xa9kJd
about: Much like the discussions but more chatty
@@ -0,0 +1,33 @@
name: Feature request
description: Share your ideas for new features or improvements
labels: ["feature request"]
body:
- type: markdown
attributes:
value: |
> [!NOTE]
>
> Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
Please provide a detailed description of what the feature would do and what it would look like:
* What problem would this feature solve?
* Are there any potential downsides to this feature?
* If applicable, what would the configuration for this feature look like?
* Are there any existing examples of this feature in other software?
* If applicable, include any external documentation required to implement this feature
* Anything else you think might be relevant
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
- type: textarea
id: description
validations:
required: true
attributes:
label: Description
- type: markdown
attributes:
value: |
Thank you for taking the time to submit your idea.
+1 -7
View File
@@ -1,7 +1 @@
<!-- <!-- If your pull request adds new features, changes existing ones or fixes any bugs, please use the dev branch as the base, otherwise use the main branch -->
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
Documentation updates (including new themes) can be submitted to the main branch.
-->
+2 -1
View File
@@ -1,4 +1,5 @@
/assets /assets
/build /build
/playground /playground
glance*.yml /.idea
/glance*.yml
+3
View File
@@ -20,6 +20,9 @@ builds:
- 386 - 386
goarm: goarm:
- 7 - 7
ignore:
- goos: windows
goarch: arm
ldflags: ldflags:
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }} - -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
+3 -3
View File
@@ -1,13 +1,13 @@
FROM golang:1.22.5-alpine3.20 AS builder FROM golang:1.26.3-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app
RUN CGO_ENABLED=0 go build . RUN CGO_ENABLED=0 go build .
FROM alpine:3.20 FROM alpine:3.22
WORKDIR /app 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"]
+2 -3
View File
@@ -1,8 +1,7 @@
FROM alpine:3.20 FROM alpine:3.22
WORKDIR /app WORKDIR /app
COPY glance . COPY glance .
EXPOSE 8080/tcp EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
ENTRYPOINT ["/app/glance"]
+400 -64
View File
@@ -1,111 +1,447 @@
<p align="center"><em>What if you could see everything at a...</em></p> <p align="center"><img src="docs/logo.png"></p>
<h1 align="center">Glance</h1> <h1 align="center">Glance</h1>
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p> <p align="center">
<a href="#installation">Install</a> •
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
<a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> •
<a href="https://github.com/sponsors/glanceapp">Sponsor</a>
</p>
<p align="center">
<a href="https://github.com/glanceapp/community-widgets">Community widgets</a> •
<a href="docs/preconfigured-pages.md">Preconfigured pages</a> •
<a href="docs/themes.md">Themes</a>
</p>
![example homepage](docs/images/readme-main-image.png) <p align="center">A lightweight, highly customizable dashboard that displays<br> your feeds in a beautiful, streamlined interface</p>
### Features ![](docs/images/readme-main-image.png)
#### Various widgets
## Features
### Various widgets
* RSS feeds * RSS feeds
* Subreddit posts * Subreddit posts
* Weather * Hacker News posts
* Bookmarks * Weather forecasts
* Hacker News * YouTube channel uploads
* Lobsters * Twitch channels
* Latest YouTube videos from specific channels * Market prices
* Clock * Docker containers status
* Calendar * Server stats
* Stocks * Custom widgets
* iframe * [and many more...](docs/configuration.md#configuring-glance)
* Twitch channels & top games
* GitHub releases
* Repository overview
* Site monitor
* Search box
#### Themeable ### Fast and lightweight
![multiple color schemes example](docs/images/themes-example.png) * Low memory usage
* Few dependencies
* Minimal vanilla JS
* Single <20mb binary available for multiple OSs & architectures and just as small Docker container
* Uncached pages usually load within ~1s (depending on internet speed and number of widgets)
#### Optimized for mobile devices ### Tons of customizability
![mobile device previews](docs/images/mobile-preview.png) * Different layouts
* As many pages/tabs as you need
* Numerous configuration options for each widget
* Multiple styles for some widgets
* Custom CSS
#### Fast and lightweight ### Optimized for mobile devices
* Minimal JS, no bloated frameworks Because you'll want to take it with you on the go.
* Very few dependencies
* Single, easily distributed <15mb binary and just as small docker container
* All requests are parallelized, uncached pages usually load within ~1s (depending on internet speed and number of widgets)
### Configuration ![](docs/images/mobile-preview.png)
Checkout the [configuration docs](docs/configuration.md) to learn more. A [preconfigured page](docs/configuration.md#preconfigured-page) is also available to get you started quickly.
### Installation ### Themeable
> [!CAUTION] Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
>
> The project is under active development, expect things to break every once in a while.
#### Manual ![](docs/images/themes-example.png)
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
<br>
## Configuration
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md#configuring-glance).
<details>
<summary><strong>Preview example configuration file</strong></summary>
<br>
```yaml
pages:
- name: Home
columns:
- size: small
widgets:
- type: calendar
first-day-of-week: monday
- type: rss
limit: 10
collapse-after: 3
cache: 12h
feeds:
- url: https://selfh.st/rss/
title: selfh.st
limit: 4
- url: https://ciechanow.ski/atom.xml
- url: https://www.joshwcomeau.com/rss.xml
title: Josh Comeau
- url: https://samwho.dev/rss.xml
- url: https://ishadeed.com/feed.xml
title: Ahmad Shadeed
- type: twitch-channels
channels:
- theprimeagen
- j_blow
- piratesoftware
- cohhcarnage
- christitustech
- EJ_SA
- size: full
widgets:
- type: group
widgets:
- type: hacker-news
- type: lobsters
- type: videos
channels:
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
- UCsBjURrPoezykLs9EqgamOA # Fireship
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
- type: group
widgets:
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- size: small
widgets:
- type: weather
location: London, United Kingdom
units: metric
hour-format: 12h
- type: markets
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
name: Bitcoin
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
- type: releases
cache: 1d
repositories:
- glanceapp/glance
- go-gitea/gitea
- immich-app/immich
- syncthing/syncthing
```
</details>
<br>
## Installation
Choose one of the following methods:
<details>
<summary><strong>Docker compose using provided directory structure (recommended)</strong></summary>
<br>
Create a new directory called `glance` as well as the template files within it by running:
```bash
mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
```
*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
Then, edit the following files as desired:
* `docker-compose.yml` to configure the port, volumes and other containery things
* `config/home.yml` to configure the widgets or layout of the home page
* `config/glance.yml` if you want to change the theme or add more pages
<details>
<summary>Other files you may want to edit</summary>
* `.env` to configure environment variables that will be available inside configuration files
* `assets/user.css` to add custom CSS
</details>
When ready, run:
```bash
docker compose up -d
```
If you encounter any issues, you can check the logs by running:
```bash
docker compose logs
```
<hr>
</details>
<details>
<summary><strong>Docker compose manual</strong></summary>
<br>
Create a `docker-compose.yml` file with the following contents:
```yaml
services:
glance:
container_name: glance
image: glanceapp/glance
restart: unless-stopped
volumes:
- ./config:/app/config
ports:
- 8080:8080
```
Then, create a new directory called `config` and download the example starting [`glance.yml`](https://github.com/glanceapp/glance/blob/main/docs/glance.yml) file into it by running:
```bash
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
```
Feel free to edit the `glance.yml` file to your liking, and when ready run:
```bash
docker compose up -d
```
If you encounter any issues, you can check the logs by running:
```bash
docker logs glance
```
<hr>
</details>
<details>
<summary><strong>Manual binary installation</strong></summary>
<br>
Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
### Linux
Visit the [latest release page](https://github.com/glanceapp/glance/releases/latest) for available binaries. You can place the binary in `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). By default, when running the binary, it will look for a `glance.yml` file in the directory it's placed in. To specify a different path for the config file, use the `--config` option:
```bash ```bash
/opt/glance/glance --config /etc/glance.yml /opt/glance/glance --config /etc/glance.yml
``` ```
#### Docker To grab a starting template for the config file, run:
> [!IMPORTANT]
>
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
```bash ```bash
docker run -d -p 8080:8080 \ wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
-v ./glance.yml:/app/glance.yml \
-v /etc/timezone:/etc/timezone:ro \
-v /etc/localtime:/etc/localtime:ro \
glanceapp/glance
``` ```
Or if you prefer docker compose: ### Windows
Download and extract the executable from the [latest release](https://github.com/glanceapp/glance/releases/latest) (most likely the file called `glance-windows-amd64.zip` if you're on a 64-bit system) and place it in a folder of your choice. Then, create a new text file called `glance.yml` in the same folder and paste the content from [here](https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml) in it. You should then be able to run the executable and access the dashboard by visiting `http://localhost:8080` in your browser.
<hr>
</details>
<details>
<summary><strong>Other</strong></summary>
<br>
Glance can also be installed through the following 3rd party channels:
* [Proxmox VE Helper Script](https://community-scripts.org/scripts/glance?id=glance)
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
* [Hostinger](https://www.hostinger.com/vps/docker/glance)
* [Coolify.io](https://coolify.io/docs/services/glance/)
<hr>
</details>
<br>
## Common issues
<details>
<summary><strong>Requests timing out</strong></summary>
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
```yaml
networks:
podman:
external: true
```
</details>
<details>
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
</details>
<details>
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
</details>
<br>
## FAQ
<details>
<summary><strong>Does the information on the page update automatically?</strong></summary>
No, a page refresh is required to update the information. Some things do dynamically update where it makes sense, like the clock widget and the relative time showing how long ago something happened.
</details>
<details>
<summary><strong>How frequently do widgets update?</strong></summary>
No requests are made periodically in the background, information is only fetched upon loading the page and then cached. The default cache lifetime is different for each widget and can be configured.
</details>
<details>
<summary><strong>Can I create my own widgets?</strong></summary>
Yes, there are multiple ways to create custom widgets:
* `iframe` widget - allows you to embed things from other websites
* `html` widget - allows you to insert your own static HTML
* `extension` widget - fetch HTML from a URL
* `custom-api` widget - fetch JSON from a URL and render it using custom HTML
</details>
<details>
<summary><strong>Can I change the title of a widget?</strong></summary>
Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration:
```yaml ```yaml
services: - type: rss
glance: title: My custom title
image: glanceapp/glance
volumes: - type: markets
- ./glance.yml:/app/glance.yml title: My custom title
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - type: videos
ports: title: My custom title
- 8080:8080
restart: unless-stopped # and so on for all widgets...
``` ```
</details>
### Building from source <br>
Requirements: [Go](https://go.dev/dl/) >= v1.22 ## Feature requests
To build: New feature suggestions are always welcome and will be considered, though please keep in mind that some of them may be out of scope for what the project is trying to achieve (or is reasonably capable of). If you have an idea for a new feature and would like to share it, you can do so [here](https://github.com/glanceapp/glance/issues/new?template=feature_request.yml).
Feature requests are tagged with one of the following:
* [Roadmap](https://github.com/glanceapp/glance/labels/roadmap) - will be implemented in a future release
* [Backlog](https://github.com/glanceapp/glance/labels/backlog) - may be implemented in the future but needs further feedback or interest from the community
* [Icebox](https://github.com/glanceapp/glance/labels/icebox) - no plans to implement as it doesn't currently align with the project's goals or capabilities, may be revised at a later date
<br>
## Building from source
Choose one of the following methods:
<details>
<summary><strong>Build binary with Go</strong></summary>
<br>
Requirements: [Go](https://go.dev/dl/) >= v1.23
To build the project for your current OS and architecture, run:
```bash ```bash
go build -o build/glance . go build -o build/glance .
``` ```
To run: To build for a specific OS and architecture, run:
```bash
GOOS=linux GOARCH=amd64 go build -o build/glance .
```
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
```bash ```bash
go run . go run .
``` ```
<hr>
</details>
### Building Docker image <details>
<summary><strong>Build project and Docker image with Docker</strong></summary>
<br>
Build the image: Requirements: [Docker](https://docs.docker.com/engine/install/)
**Make sure to replace "owner" with your name or organization.** To build the project and image using just Docker, run:
*(replace `owner` with your name or organization)*
```bash ```bash
docker build -t owner/glance:latest . docker build -t owner/glance:latest .
``` ```
Push the image to your registry: If you wish to push the image to a registry (by default Docker Hub), run:
```bash ```bash
docker push owner/glance:latest docker push owner/glance:latest
``` ```
<hr>
</details>
<br>
## Contributing guidelines
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
* Please don't submit PRs for feature requests that are either in the roadmap<sup>[1]</sup>, backlog<sup>[2]</sup> or icebox<sup>[3]</sup>
* Use `dev` for the base branch if you're adding new features or fixing bugs, otherwise use `main`
* Avoid introducing new dependencies
* Avoid making backwards-incompatible configuration changes
* Avoid introducing new colors or hard-coding colors, use the standard `primary`, `positive` and `negative`
* For icons, try to use [heroicons](https://heroicons.com/) where applicable
* Provide a screenshot of the changes if UI related where possible
* No `package.json`
<details>
<summary><strong><sup>[1] [2] [3]</sup></strong></summary>
[1] The feature likely already has work put into it that may conflict with your implementation
[2] The demand, implementation or functionality for this feature is not yet clear
[3] No plans to add this feature for the time being
</details>
<br>
## Thank you
To all the people who were generous enough to [sponsor](https://github.com/sponsors/glanceapp) the project and to everyone who has contributed in any way, be it PRs, submitting issues, helping others in the discussions or Discord server, creating guides and tools or just mentioning Glance on social media. Your support is greatly appreciated and helps keep the project going.
+1418 -125
View File
File diff suppressed because it is too large Load Diff
+475
View File
@@ -0,0 +1,475 @@
[Jump to function definitions](#functions)
## Examples
The best way to get an idea of how the templates work would be with a bunch examples. Here are the most common use cases:
JSON response:
```json
{
"title": "My Title",
"content": "My Content",
}
```
To access the two fields in the JSON response, you would use the following:
```html
<div>{{ .JSON.String "title" }}</div>
<div>{{ .JSON.String "content" }}</div>
```
Output:
```html
<div>My Title</div>
<div>My Content</div>
```
<hr>
JSON response:
```json
{
"author": "John Doe",
"posts": [
{
"title": "My Title",
"content": "My Content"
},
{
"title": "My Title 2",
"content": "My Content 2"
}
]
}
```
To loop through the array of posts, you would use the following:
```html
{{ range .JSON.Array "posts" }}
<div>{{ .String "title" }}</div>
<div>{{ .String "content" }}</div>
{{ end }}
```
Output:
```html
<div>My Title</div>
<div>My Content</div>
<div>My Title 2</div>
<div>My Content 2</div>
```
Notice the missing `.JSON` when accessing the title and content, this is because the range function sets the context to the current array element.
If you want to access the top-level context within the range, you can use the following:
```html
{{ range .JSON.Array "posts" }}
<div>{{ .String "title" }}</div>
<div>{{ .String "content" }}</div>
<div>{{ $.JSON.String "author" }}</div>
{{ end }}
```
Output:
```html
<div>My Title</div>
<div>My Content</div>
<div>John Doe</div>
<div>My Title 2</div>
<div>My Content 2</div>
<div>John Doe</div>
```
<hr>
JSON response:
```json
[
"Apple",
"Banana",
"Cherry",
"Watermelon"
]
```
Somewhat awkwardly, when the current context is a basic type that isn't an object, the way you specify its type is to use an empty string as the key. So, to loop through the array of strings, you would use the following:
```html
{{ range .JSON.Array "" }}
<div>{{ .String "" }}</div>
{{ end }}
```
Output:
```html
<div>Apple</div>
<div>Banana</div>
<div>Cherry</div>
<div>Watermelon</div>
```
To access an item at a specific index, you could use the following:
```html
<div>{{ .JSON.String "0" }}</div>
```
Output:
```html
<div>Apple</div>
```
<hr>
JSON response:
```json
{
"user": {
"address": {
"city": "New York",
"state": "NY"
}
}
}
```
To easily access deeply nested objects, you can use the following dot notation:
```html
<div>{{ .JSON.String "user.address.city" }}</div>
<div>{{ .JSON.String "user.address.state" }}</div>
```
Output:
```html
<div>New York</div>
<div>NY</div>
```
Using indexes anywhere in the path is also supported:
```json
{
"users": [
{
"name": "John Doe"
},
{
"name": "Jane Doe"
}
]
}
```
```html
<div>{{ .JSON.String "users.0.name" }}</div>
<div>{{ .JSON.String "users.1.name" }}</div>
```
Output:
```html
<div>John Doe</div>
<div>Jane Doe</div>
```
<hr>
JSON response:
```json
{
"user": {
"name": "John Doe",
"age": 30
}
}
```
To check if a field exists, you can use the following:
```html
{{ if .JSON.Exists "user.age" }}
<div>{{ .JSON.Int "user.age" }}</div>
{{ else }}
<div>Age not provided</div>
{{ end }}
```
Output:
```html
<div>30</div>
```
<hr>
JSON response:
```json
{
"price": 100,
"discount": 10
}
```
Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
```html
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
```
Output:
```html
<div>90</div>
```
Other operations include `add`, `mul`, `div` and `mod`.
<hr>
JSON response:
```json
{
"posts": [
{
"title": "Exploring the Depths of Quantum Computing",
"date": "2023-10-27T10:00:00Z"
},
{
"title": "A Beginner's Guide to Sustainable Living",
"date": "2023-11-15T14:30:00+01:00"
},
{
"title": "The Art of Baking Sourdough Bread",
"date": "2023-12-03T08:45:22-08:00"
}
]
}
```
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
```
{{ range .JSON.Array "posts" }}
<div>{{ .String "title" }}</div>
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
{{ end }}
```
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
Output:
```html
<div>Exploring the Depths of Quantum Computing</div>
<div data-dynamic-relative-time="1698400800"></div>
<div>A Beginner's Guide to Sustainable Living</div>
<div data-dynamic-relative-time="1700055000"></div>
<div>The Art of Baking Sourdough Bread</div>
<div data-dynamic-relative-time="1701621922"></div>
```
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
<hr>
In some instances, you may want to know the status code of the response. This can be done using the following:
```html
{{ if eq .Response.StatusCode 200 }}
<p>Success!</p>
{{ else }}
<p>Failed to fetch data</p>
{{ end }}
```
You can also access the response headers:
```html
<div>{{ .Response.Header.Get "Content-Type" }}</div>
```
<hr>
JSON response:
```json
{"name": "Steve", "age": 30}
{"name": "Alex", "age": 25}
{"name": "John", "age": 35}
```
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
```yaml
- type: custom-api
skip-json-validation: true
```
Then, to iterate over each object you can use `.JSONLines`:
```html
{{ range .JSONLines }}
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
{{ end }}
```
Output:
```html
<p>Steve is 30 years old</p>
<p>Alex is 25 years old</p>
<p>John is 35 years old</p>
```
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
```html
{{ range .JSON.Array "..#.name" }}
<p>{{ .String "" }}</p>
{{ end }}
```
Output:
```html
<p>Steve</p>
<p>Alex</p>
<p>John</p>
```
<hr>
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
```yaml
- type: custom-api
url: https://api.example.com/get-id-of-something
template: |
{{ $theID := .JSON.String "id" }}
{{
$something := newRequest (concat "https://api.example.com/something/" $theID)
| withParameter "key" "value"
| withHeader "Authorization" "Bearer token"
| getResponse
}}
{{ $something.JSON.String "title" }}
```
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
```yaml
- type: custom-api
title: Events from the last 24h
template: |
{{
$events := newRequest "https://api.example.com/events"
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
| getResponse
}}
{{ if eq $events.Response.StatusCode 200 }}
{{ range $events.JSON.Array "events" }}
<div>{{ .String "title" }}</div>
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
{{ end }}
{{ else }}
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
{{ end }}
```
*Note that you need to manually check for the correct status code.*
## Functions
The following functions are available on the `JSON` object:
- `String(key string) string`: Returns the value of the key as a string.
- `Int(key string) int`: Returns the value of the key as an integer.
- `Float(key string) float`: Returns the value of the key as a float.
- `Bool(key string) bool`: Returns the value of the key as a boolean.
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
The following functions are available on the `Options` object:
- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist.
- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist.
- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist.
- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist.
- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist.
The following helper functions provided by Glance are available:
- `toFloat(i int) float`: Converts an integer to a float.
- `toInt(f float) int`: Converts a float to an integer.
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
- `now() time.Time`: Returns the current time.
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC.
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
- `add(a, b float) float`: Adds two numbers.
- `sub(a, b float) float`: Subtracts two numbers.
- `mul(a, b float) float`: Multiplies two numbers.
- `div(a, b float) float`: Divides two numbers.
- `mod(a, b int) int`: Remainder after dividing a by b (a % b).
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
- `concat(strings ...string) string`: Concatenates multiple strings together.
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time.
- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time.
The following helper functions provided by Go's `text/template` are available:
- `eq(a, b any) bool`: Compares two values for equality.
- `ne(a, b any) bool`: Compares two values for inequality.
- `lt(a, b any) bool`: Compares two values for less than.
- `le(a, b any) bool`: Compares two values for less than or equal to.
- `gt(a, b any) bool`: Compares two values for greater than.
- `ge(a, b any) bool`: Compares two values for greater than or equal to.
- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values.
- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values.
- `not(a bool) bool`: Returns the opposite of the value.
- `index(a any, b int) any`: Returns the value at the specified index of an array.
- `len(a any) int`: Returns the length of an array.
- `printf(format string, a ...any) string`: Returns a formatted string.
+6
View File
@@ -26,9 +26,15 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t
### `Widget-Title` ### `Widget-Title`
Used to specify the title of the widget. If not provided, the widget's title will be "Extension". Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
### `Widget-Title-URL`
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
### `Widget-Content-Type` ### `Widget-Content-Type`
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text. Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
### `Widget-Content-Frameless`
When set to `true`, the widget's content will be displayed without the default background or "frame".
## Content Types ## Content Types
> [!NOTE] > [!NOTE]
+105
View File
@@ -0,0 +1,105 @@
pages:
- name: Home
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
# hide-desktop-navigation: true
columns:
- size: small
widgets:
- type: calendar
first-day-of-week: monday
- type: rss
limit: 10
collapse-after: 3
cache: 12h
feeds:
- url: https://selfh.st/rss/
title: selfh.st
limit: 4
- url: https://ciechanow.ski/atom.xml
- url: https://www.joshwcomeau.com/rss.xml
title: Josh Comeau
- url: https://samwho.dev/rss.xml
- url: https://ishadeed.com/feed.xml
title: Ahmad Shadeed
- type: twitch-channels
channels:
- theprimeagen
- j_blow
- giantwaffle
- cohhcarnage
- christitustech
- EJ_SA
- size: full
widgets:
- type: group
widgets:
- type: hacker-news
- type: lobsters
- type: videos
channels:
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
- UCsBjURrPoezykLs9EqgamOA # Fireship
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
- type: group
widgets:
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- size: small
widgets:
- type: weather
location: London, United Kingdom
units: metric # alternatively "imperial"
hour-format: 12h # alternatively "24h"
# Optionally hide the location from being displayed in the widget
# hide-location: true
- type: markets
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
name: Bitcoin
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
- type: releases
cache: 1d
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
# read-only token from your Github account settings and use it here to increase the limit.
# token: ...
repositories:
- glanceapp/glance
- go-gitea/gitea
- immich-app/immich
- syncthing/syncthing
# Add more pages here:
# - name: Your page name
# columns:
# - size: small
# widgets:
# # Add widgets here
# - size: full
# widgets:
# # Add widgets here
# - size: small
# widgets:
# # Add widgets here
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+84 -80
View File
@@ -4,6 +4,10 @@ Don't want to spend time configuring pages from scratch? No problem! Simply copy
Pull requests with your page configurations are welcome! Pull requests with your page configurations are welcome!
> [!NOTE]
>
> Pages must be placed under a top level `pages:` key, you can read more about that [here](configuration.md#pages).
## Startpage ## Startpage
![](images/startpage-preview.png) ![](images/startpage-preview.png)
@@ -82,92 +86,92 @@ Pull requests with your page configurations are welcome!
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary> <summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
```yaml ```yaml
- name: Markets - name: Markets
columns: columns:
- size: small - size: small
widgets: widgets:
- type: markets - type: markets
title: Indices title: Indices
markets: markets:
- symbol: SPY - symbol: SPY
name: S&P 500 name: S&P 500
- symbol: DX-Y.NYB - symbol: DX-Y.NYB
name: Dollar Index name: Dollar Index
- type: markets - type: markets
title: Crypto title: Crypto
markets: markets:
- symbol: BTC-USD - symbol: BTC-USD
name: Bitcoin name: Bitcoin
- symbol: ETH-USD - symbol: ETH-USD
name: Ethereum name: Ethereum
- type: markets - type: markets
title: Stocks title: Stocks
sort-by: absolute-change sort-by: absolute-change
markets: markets:
- symbol: NVDA - symbol: NVDA
name: NVIDIA name: NVIDIA
- symbol: AAPL - symbol: AAPL
name: Apple name: Apple
- symbol: MSFT - symbol: MSFT
name: Microsoft name: Microsoft
- symbol: GOOGL - symbol: GOOGL
name: Google name: Google
- symbol: AMD - symbol: AMD
name: AMD name: AMD
- symbol: RDDT - symbol: RDDT
name: Reddit name: Reddit
- symbol: AMZN - symbol: AMZN
name: Amazon name: Amazon
- symbol: TSLA - symbol: TSLA
name: Tesla name: Tesla
- symbol: INTC - symbol: INTC
name: Intel name: Intel
- symbol: META - symbol: META
name: Meta name: Meta
- size: full - size: full
widgets: widgets:
- type: rss - type: rss
title: News title: News
style: horizontal-cards style: horizontal-cards
feeds: feeds:
- url: https://feeds.bloomberg.com/markets/news.rss - url: https://feeds.bloomberg.com/markets/news.rss
title: Bloomberg title: Bloomberg
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml - url: https://moxie.foxbusiness.com/google-publisher/markets.xml
title: Fox Business title: Fox Business
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml - url: https://moxie.foxbusiness.com/google-publisher/technology.xml
title: Fox Business title: Fox Business
- type: group - type: group
widgets: widgets:
- type: reddit - type: reddit
show-thumbnails: true show-thumbnails: true
subreddit: technology subreddit: technology
- type: reddit - type: reddit
show-thumbnails: true show-thumbnails: true
subreddit: wallstreetbets subreddit: wallstreetbets
- type: videos - type: videos
style: grid-cards style: grid-cards
collapse-after-rows: 3 collapse-after-rows: 3
channels: channels:
- UCvSXMi2LebwJEM1s4bz5IBA # New Money - UCvSXMi2LebwJEM1s4bz5IBA # New Money
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan - UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve - UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
- size: small - size: small
widgets: widgets:
- type: rss - type: rss
title: News title: News
limit: 30 limit: 30
collapse-after: 13 collapse-after: 13
feeds: feeds:
- url: https://www.ft.com/technology?format=rss - url: https://www.ft.com/technology?format=rss
title: Financial Times title: Financial Times
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml - url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
title: Wall Street Journal title: Wall Street Journal
``` ```
</details> </details>
+43
View File
@@ -53,6 +53,16 @@ theme:
primary-color: 97 13 80 primary-color: 97 13 80
``` ```
### Gruvbox Dark
![screenshot](images/themes/gruvbox.png)
```yaml
theme:
background-color: 0 0 16
primary-color: 43 59 81
positive-color: 61 66 44
negative-color: 6 96 59
```
### Kanagawa Dark ### Kanagawa Dark
![screenshot](images/themes/kanagawa-dark.png) ![screenshot](images/themes/kanagawa-dark.png)
```yaml ```yaml
@@ -72,6 +82,39 @@ theme:
negative-color: 209 88 54 negative-color: 209 88 54
``` ```
### Dracula
![screenshot](images/themes/dracula.png)
```yaml
theme:
background-color: 231 15 21
primary-color: 265 89 79
contrast-multiplier: 1.2
positive-color: 135 94 66
negative-color: 0 100 67
```
### Shades of Purple
![screenshot](images/themes/shades-of-purple.png)
```yaml
theme:
background-color: 243 33 25
contrast-multiplier: 1.2
primary-color: 50 100 49
positive-color: 98 82 71
negative-color: 12 77 52
```
### Neon Pink
![screenshot](images/themes/neon-pink.png)
```yaml
theme:
background-color: 240 27 11
contrast-multiplier: 1.5
primary-color: 321 100 71
positive-color: 165 78 51
negative-color: 360 100 71
```
## Light ## Light
### Catppuccin Latte ### Catppuccin Latte
+57
View File
@@ -0,0 +1,57 @@
## Upgrading to v0.7.0 from previous versions
In essence, the `glance.yml` file has been moved from the root of the project to a `config/` directory and you now need to mount that directory to `/app/config` in the container.
### Before
Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
```yaml
services:
glance:
image: glanceapp/glance
volumes:
- ./glance.yml:/app/glance.yml
ports:
- 8080:8080
```
And expected you to have the following directory structure:
```plaintext
glance/
docker-compose.yml
glance.yml
```
### After
With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
```yaml
services:
glance:
container_name: glance
image: glanceapp/glance
volumes:
- ./config:/app/config
ports:
- 8080:8080
```
And expects you to have the following directory structure:
```plaintext
glance/
docker-compose.yml
config/
glance.yml
```
## Why this change was necessary
1. Mounting a file rather than a directory is not common practice and leads to some issues, such as creating a directory if the file is not present, which has tripped up multiple people and caused unnecessary confusion
2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
3. v0.7.0 added the ability to include config files, so you'd have to make this change anyways if you wanted to take advantage of that feature
Taking all of these into account, it felt like the right time to implement the change.
+22 -5
View File
@@ -1,19 +1,36 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.22.5 go 1.26.3
require ( require (
github.com/fsnotify/fsnotify v1.9.0
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.16.0 github.com/refraction-networking/utls v1.8.2
github.com/shirou/gopsutil/v4 v4.25.5
github.com/tidwall/gjson v1.18.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/text v0.25.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/PuerkitoBio/goquery v1.9.2 // indirect github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/brotli v1.0.6 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.27.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.33.0 // indirect
) )
+85 -13
View File
@@ -1,13 +1,29 @@
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
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/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
@@ -19,47 +35,103 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.1.0/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-56
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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
-109
View File
@@ -1,109 +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")
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")
)
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))
}
-23
View File
@@ -1,23 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-24 list-with-separator">
{{ range .Groups }}
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2">
{{ range .Links }}
<li class="flex items-center gap-10">
{{ if ne "" .Icon }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
</div>
{{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
</div>
{{ end }}
-13
View File
@@ -1,13 +0,0 @@
{{ if .Page.ShowMobileHeader }}
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
{{ end }}
<div class="page-columns">
{{ range .Page.Columns }}
<div class="page-column page-column-{{ .Size }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
</div>
{{ end }}
</div>
-24
View File
@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
<head>
{{ block "document-head-before" . }}{{ end }}
<title>{{ block "document-title" . }}{{ end }}</title>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }}
</head>
<body>
{{ template "document-body" . }}
</body>
</html>
@@ -1,49 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Posts }}
<li>
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }}
{{ if .IsCrosspost }}
<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="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
{{ else if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if .HasTargetUrl }}
<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" />
</svg>
{{ else }}
<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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
{{ end }}
{{ end }}
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{ if gt (len .Tags) 0 }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
{{ range .Tags }}
<li>{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }}
</ul>
</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}
-20
View File
@@ -1,20 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="widget-group-header">
<div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
{{ end }}
</div>
</div>
<div class="widget-group-contents">
{{ range $i, $widget := .Widgets }}
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
{{ end }}
</div>
{{ end }}
@@ -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>
-80
View File
@@ -1,80 +0,0 @@
{{ template "document.html" . }}
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }}
<script>
const pageData = {
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
</script>
{{ 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" }}
{{ template "page-style-overrides.gotmpl" . }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
{{ end }}
{{ end }}
{{ define "navigation-links" }}
{{ range .App.Config.Pages }}
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
{{ end }}
{{ end }}
{{ define "document-body" }}
<div class="flex flex-column height-100">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
<div class="nav flex grow">
{{ template "navigation-links" . }}
</div>
</div>
</div>
{{ end }}
<div class="mobile-navigation">
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
</div>
</div>
<div class="content-bounds grow">
<div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
</div>
</div>
</div>
{{ if not .App.Config.Branding.HideFooter }}
<div class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</div>
{{ end }}
<div class="mobile-navigation-offset"></div>
</div>
{{ end }}
-61
View File
@@ -1,61 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
</ul>
{{ if gt (len .RepositoryDetails.Commits) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.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>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.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>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.Issues) 0 }}
<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>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.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>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}
@@ -1,23 +0,0 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}}
<div class="widget-header">
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
{{ if and .Error .ContentAvailable }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }}
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }}
</div>
{{ end }}
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ if .ContentAvailable }}
{{ block "widget-content" . }}{{ end }}
{{ else }}
<div class="widget-error-header">
<div class="color-negative size-h3">ERROR</div>
<div class="widget-error-icon"></div>
</div>
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
{{ end}}
</div>
</div>
-99
View File
@@ -1,99 +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
}
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
ResponseTime: int(responseJson.ResponseTime * 1000),
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
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,
PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100),
})
}
// Adguard _should_ return data for the last 24 hours in a 1 hour interval
if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 {
return stats, nil
}
maxQueriesInSeries := 0
for i := 0; i < 8; i++ {
queries := 0
blocked := 0
for j := 0; j < 3; j++ {
queries += responseJson.QueriesSeries[i*3+j]
blocked += responseJson.BlockedSeries[i*3+j]
}
stats.Series[i] = DNSStatsSeries{
Queries: queries,
Blocked: blocked,
PercentBlocked: int(float64(blocked) / float64(queries) * 100),
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < 8; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}
-53
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()
}
-102
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
}
-97
View File
@@ -1,97 +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"`
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 = ExtensionContentUnknown
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}
-48
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
}
-98
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)
}
-91
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
}
-77
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
}
-109
View File
@@ -1,109 +0,0 @@
package feed
import (
"errors"
"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 map[string]int `json:"top_ads"`
DomainsBlocked int `json:"domains_being_blocked"`
}
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 {
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
}
-241
View File
@@ -1,241 +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)
})
}
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
}
-146
View File
@@ -1,146 +0,0 @@
package feed
import (
"fmt"
"html"
"net/http"
"net/url"
"strings"
"time"
)
type subredditResponseJson struct {
Data struct {
Children []struct {
Data struct {
Id string `json:"id"`
Title string `json:"title"`
Upvotes int `json:"ups"`
Url string `json:"url"`
Time float64 `json:"created"`
CommentsCount int `json:"num_comments"`
Domain string `json:"domain"`
Permalink string `json:"permalink"`
Stickied bool `json:"stickied"`
Pinned bool `json:"pinned"`
IsSelf bool `json:"is_self"`
Thumbnail string `json:"thumbnail"`
Flair string `json:"link_flair_text"`
ParentList []struct {
Id string `json:"id"`
Subreddit string `json:"subreddit"`
Permalink string `json:"permalink"`
} `json:"crosspost_parent_list"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
template = strings.ReplaceAll(template, "{POST-ID}", postId)
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
return template
}
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
query := url.Values{}
var requestUrl string
if search != "" {
query.Set("q", search+" subreddit:"+subreddit)
query.Set("sort", sort)
}
if sort == "top" {
query.Set("t", topPeriod)
}
if search != "" {
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
} else {
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
}
if requestUrlTemplate != "" {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
}
request, err := http.NewRequest("GET", requestUrl, nil)
if err != nil {
return nil, err
}
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
addBrowserUserAgentHeader(request)
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
if err != nil {
return nil, err
}
if len(responseJson.Data.Children) == 0 {
return nil, fmt.Errorf("no posts found")
}
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
for i := range responseJson.Data.Children {
post := &responseJson.Data.Children[i].Data
if post.Stickied || post.Pinned {
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink
} else {
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
}
forumPost := ForumPost{
Title: html.UnescapeString(post.Title),
DiscussionUrl: commentsUrl,
TargetUrlDomain: post.Domain,
CommentCount: post.CommentsCount,
Score: post.Upvotes,
TimePosted: time.Unix(int64(post.Time), 0),
}
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
forumPost.ThumbnailUrl = post.Thumbnail
}
if !post.IsSelf {
forumPost.TargetUrl = post.Url
}
if showFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair)
}
if len(post.ParentList) > 0 {
forumPost.IsCrosspost = true
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
if commentsUrlTemplate == "" {
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
} else {
forumPost.TargetUrl = templateRedditCommentsURL(
commentsUrlTemplate,
post.ParentList[0].Subreddit,
post.ParentList[0].Id,
post.ParentList[0].Permalink,
)
}
}
posts = append(posts, forumPost)
}
return posts, nil
}
-69
View File
@@ -1,69 +0,0 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
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 ReleaseSourceGithub:
return fetchLatestGithubRelease(request)
case ReleaseSourceGitlab:
return fetchLatestGitLabRelease(request)
case ReleaseSourceDockerHub:
return fetchLatestDockerHubRelease(request)
}
return nil, errors.New("unsupported source")
}
-246
View File
@@ -1,246 +0,0 @@
package feed
import (
"context"
"fmt"
"html"
"log/slog"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/mmcdole/gofeed"
gofeedext "github.com/mmcdole/gofeed/extensions"
)
type RSSFeedItem struct {
ChannelName string
ChannelURL string
Title string
Link string
ImageURL string
Categories []string
Description string
PublishedAt time.Time
}
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
return description
}
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
description += "…"
}
return description
}
type RSSFeedRequest struct {
Url string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
IsDetailed bool `yaml:"-"`
}
type RSSFeedItems []RSSFeedItem
func (f RSSFeedItems) SortByNewest() RSSFeedItems {
sort.Slice(f, func(i, j int) bool {
return f[i].PublishedAt.After(f[j].PublishedAt)
})
return f
}
var feedParser = gofeed.NewParser()
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
if err != nil {
return nil, err
}
items := make(RSSFeedItems, 0, len(feed.Items))
for i := range feed.Items {
item := feed.Items[i]
rssItem := RSSFeedItem{
ChannelURL: feed.Link,
}
if request.ItemLinkPrefix != "" {
rssItem.Link = request.ItemLinkPrefix + item.Link
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
rssItem.Link = item.Link
} else {
parsedUrl, err := url.Parse(feed.Link)
if err != nil {
parsedUrl, err = url.Parse(request.Url)
}
if err == nil {
var link string
if len(item.Link) > 0 && item.Link[0] == '/' {
link = item.Link
} else {
link = "/" + item.Link
}
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
}
}
if item.Title != "" {
rssItem.Title = item.Title
} else {
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
}
if request.IsDetailed {
if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
}
if !request.HideCategories {
var categories = make([]string, 0, 6)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
}
rssItem.Categories = categories
}
}
if request.Title != "" {
rssItem.ChannelName = request.Title
} else {
rssItem.ChannelName = feed.Title
}
if item.Image != nil {
rssItem.ImageURL = item.Image.URL
} else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = url
} else if feed.Image != nil {
if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
} else {
rssItem.ImageURL = feed.Image.URL
}
}
if item.PublishedParsed != nil {
rssItem.PublishedAt = *item.PublishedParsed
} else {
rssItem.PublishedAt = time.Now()
}
items = append(items, rssItem)
}
return items, nil
}
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
if ext.Name == "thumbnail" || ext.Name == "image" {
if url, ok := ext.Attrs["url"]; ok {
return url
}
}
if ext.Children != nil {
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
return url
}
}
}
}
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
feeds, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
failed := 0
entries := make(RSSFeedItems, 0, len(feeds)*10)
for i := range feeds {
if errs[i] != nil {
failed++
slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url)
continue
}
entries = append(entries, feeds[i]...)
}
if failed == len(requests) {
return nil, ErrNoContent
}
entries.SortByNewest()
if failed > 0 {
return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed)
}
return entries, nil
}
-117
View File
@@ -1,117 +0,0 @@
package feed
import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"time"
)
var (
ErrNoContent = errors.New("failed to retrieve any content")
ErrPartialContent = errors.New("failed to retrieve some of the content")
)
func percentChange(current, previous float64) float64 {
return (current/previous - 1) * 100
}
func extractDomainFromUrl(u string) string {
if u == "" {
return ""
}
parsed, err := url.Parse(u)
if err != nil {
return ""
}
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
}
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
if len(values) < 2 {
return ""
}
verticalPadding := height * 0.02
height -= verticalPadding * 2
coordinates := make([]string, len(values))
distanceBetweenPoints := width / float64(len(values)-1)
min := slices.Min(values)
max := slices.Max(values)
for i := range values {
coordinates[i] = fmt.Sprintf(
"%.2f,%.2f",
float64(i)*distanceBetweenPoints,
((max-values[i])/(max-min))*height+verticalPadding,
)
}
return strings.Join(coordinates, " ")
}
func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
if len(values) == 0 {
return values
}
for i := range values {
if values[i] != 0 {
continue
}
c := make([]T, 0, len(values)-1)
for i := range values {
if values[i] != 0 {
c = append(c, values[i])
}
}
return c
}
return values
}
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
if len(asRunes) > max {
return string(asRunes[:max]), true
}
return s, false
}
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return time.Now()
}
return parsed
}
func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version))
if len(version) > 0 && version[0] != 'v' {
return "v" + version
}
return version
}
-104
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
}
-115
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
}
+343
View File
@@ -0,0 +1,343 @@
package glance
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
mathrand "math/rand/v2"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const AUTH_SESSION_COOKIE_NAME = "session_token"
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
const AUTH_TOKEN_SECRET_LENGTH = 32
const AUTH_USERNAME_HASH_LENGTH = 32
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
// How long the token will be valid for
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
// How long the token has left before it should be regenerated
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
type doWhenUnauthorized int
const (
redirectToLogin doWhenUnauthorized = iota
showUnauthorizedJSON
)
type failedAuthAttempt struct {
attempts int
first time.Time
}
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
usernameHash, err := computeUsernameHash(username, secret)
if err != nil {
return "", err
}
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
copy(data, usernameHash)
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
h.Write(data)
signature := h.Sum(nil)
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
return encodedToken, nil
}
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
h.Write([]byte(username))
return h.Sum(nil), nil
}
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
tokenBytes, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return nil, false, err
}
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
return nil, false, fmt.Errorf("token length is invalid")
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
h := hmac.New(sha256.New, secretBytes[0:32])
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
expectedSignatureBytes := h.Sum(nil)
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
return nil, false, fmt.Errorf("signature does not match")
}
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
if now.Unix() > expiresTimestamp {
return nil, false, fmt.Errorf("token has expired")
}
return usernameHashBytes,
// True if the token should be regenerated
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
nil
}
func makeAuthSecretKey(length int) (string, error) {
key := make([]byte, length)
_, err := rand.Read(key)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
return
}
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
ip := a.addressOfRequest(r)
a.authAttemptsMu.Lock()
exceededRateLimit, retryAfter := func() (bool, int) {
attempt, exists := a.failedAuthAttempts[ip]
if !exists {
a.failedAuthAttempts[ip] = &failedAuthAttempt{
attempts: 1,
first: time.Now(),
}
return false, 0
}
elapsed := time.Since(attempt.first)
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
}
attempt.attempts++
return false, 0
}()
if exceededRateLimit {
a.authAttemptsMu.Unlock()
time.Sleep(waitOnFailure)
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.WriteHeader(http.StatusTooManyRequests)
return
} else {
// Clean up old failed attempts
for ipOfAttempt := range a.failedAuthAttempts {
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
delete(a.failedAuthAttempts, ipOfAttempt)
}
}
a.authAttemptsMu.Unlock()
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
err = json.Unmarshal(body, &creds)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
logAuthFailure := func() {
log.Printf(
"Failed login attempt for user '%s' from %s",
creds.Username, ip,
)
}
if len(creds.Username) == 0 || len(creds.Password) == 0 {
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
if len(creds.Username) > 50 || len(creds.Password) > 100 {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
u, exists := a.Config.Auth.Users[creds.Username]
if !exists {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
if err != nil {
log.Printf("Could not compute session token during login attempt: %v", err)
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
a.authAttemptsMu.Lock()
delete(a.failedAuthAttempts, ip)
a.authAttemptsMu.Unlock()
w.WriteHeader(http.StatusOK)
}
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
if !a.RequiresAuth {
return true
}
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
if err != nil || token.Value == "" {
return false
}
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
if err != nil {
return false
}
username, exists := a.usernameHashToUsername[string(usernameHash)]
if !exists {
return false
}
_, exists = a.Config.Auth.Users[username]
if !exists {
return false
}
if shouldRegenerate {
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
if err != nil {
log.Printf("Could not compute session token during regeneration: %v", err)
return false
}
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
}
return true
}
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
if a.isAuthorized(w, r) {
return false
}
switch fallback {
case redirectToLogin:
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
case showUnauthorizedJSON:
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
}
return true
}
// Maybe this should be a POST request instead?
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
}
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: AUTH_SESSION_COOKIE_NAME,
Value: token,
Expires: expires,
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
Path: a.Config.Server.BaseURL + "/",
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
})
}
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
if a.isAuthorized(w, r) {
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
return
}
data := &templateData{
App: a,
}
a.populateTemplateRequestData(&data.Request, r)
var responseBytes bytes.Buffer
err := loginPageTemplate.Execute(&responseBytes, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write(responseBytes.Bytes())
}
+85
View File
@@ -0,0 +1,85 @@
package glance
import (
"bytes"
"encoding/base64"
"testing"
"time"
)
func TestAuthTokenGenerationAndVerification(t *testing.T) {
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
if err != nil {
t.Fatalf("Failed to generate secret key: %v", err)
}
secretBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
t.Fatalf("Failed to decode secret key: %v", err)
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
now := time.Now()
username := "admin"
token, err := generateSessionToken(username, secretBytes, now)
if err != nil {
t.Fatalf("Failed to generate session token: %v", err)
}
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
if err != nil {
t.Fatalf("Failed to verify session token: %v", err)
}
if shouldRegen {
t.Fatal("Token should not need to be regenerated immediately after generation")
}
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
if err != nil {
t.Fatalf("Failed to compute username hash: %v", err)
}
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
t.Fatal("Username hash does not match the expected value")
}
// Test token regeneration
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
if err != nil {
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
}
if !shouldRegen {
t.Fatal("Token should have been marked for regeneration")
}
// Test token expiration
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
if err == nil {
t.Fatal("Expected token verification to fail after token expiration")
}
// Test tampered token
decodedToken, err := base64.StdEncoding.DecodeString(token)
if err != nil {
t.Fatalf("Failed to decode token: %v", err)
}
// If any of the bytes are off by 1, the token should be considered invalid
for i := range len(decodedToken) {
tampered := make([]byte, len(decodedToken))
copy(tampered, decodedToken)
tampered[i] += 1
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
if err == nil {
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
}
}
}
+129 -16
View File
@@ -2,41 +2,154 @@ package glance
import ( import (
"flag" "flag"
"fmt"
"os" "os"
"strings"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/sensors"
) )
type CliIntent uint8 type cliIntent uint8
const ( const (
CliIntentServe CliIntent = iota cliIntentVersionPrint cliIntent = iota
CliIntentCheckConfig = iota cliIntentServe
cliIntentConfigValidate
cliIntentConfigPrint
cliIntentDiagnose
cliIntentSensorsPrint
cliIntentMountpointInfo
cliIntentSecretMake
cliIntentPasswordHash
) )
type CliOptions struct { type cliOptions struct {
Intent CliIntent intent cliIntent
ConfigPath string configPath string
args []string
} }
func ParseCliOptions() (*CliOptions, error) { func parseCliOptions() (*cliOptions, error) {
var args []string
args = os.Args[1:]
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
return &cliOptions{
intent: cliIntentVersionPrint,
}, nil
}
flags := flag.NewFlagSet("", flag.ExitOnError) flags := flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() {
fmt.Println("Usage: glance [options] command")
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(" password:hash <pwd> Hash a password")
fmt.Println(" secret:make Generate a random secret key")
fmt.Println(" sensors:print List all sensors")
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
fmt.Println(" diagnose Run diagnostic checks")
}
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
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
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] == "sensors:print" {
intent = cliIntentSensorsPrint
} else if args[0] == "diagnose" {
intent = cliIntentDiagnose
} else if args[0] == "secret:make" {
intent = cliIntentSecretMake
} else {
return nil, unknownCommandErr
}
} else if len(args) == 2 {
if args[0] == "password:hash" {
intent = cliIntentPasswordHash
} else {
return nil, unknownCommandErr
}
} else if len(args) == 2 {
if args[0] == "mountpoint:info" {
intent = cliIntentMountpointInfo
} else {
return nil, unknownCommandErr
}
} else {
return nil, unknownCommandErr
} }
return &CliOptions{ return &cliOptions{
Intent: intent, intent: intent,
ConfigPath: *configPath, configPath: *configPath,
args: args,
}, nil }, nil
} }
func cliSensorsPrint() int {
tempSensors, err := sensors.SensorsTemperatures()
if err != nil {
if warns, ok := err.(*sensors.Warnings); ok {
fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
for _, w := range warns.List {
fmt.Printf(" - %v\n", w)
}
fmt.Println()
} else {
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
return 1
}
}
if len(tempSensors) == 0 {
fmt.Println("No sensors found")
return 0
}
fmt.Println("Sensors found:")
for _, sensor := range tempSensors {
fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
}
return 0
}
func cliMountpointInfo(requestedPath string) int {
usage, err := disk.Usage(requestedPath)
if err != nil {
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
if warns, ok := err.(*disk.Warnings); ok {
for _, w := range warns.List {
fmt.Printf(" - %v\n", w)
}
}
return 1
}
fmt.Println("Path:", usage.Path)
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
return 0
}
+290
View File
@@ -0,0 +1,290 @@
package glance
import (
"crypto/tls"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
const (
hslHueMax = 360
hslSaturationMax = 100
hslLightnessMax = 100
)
type hslColorField struct {
H float64
S float64
L float64
}
func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
}
func (c *hslColorField) ToHex() string {
return hslToHex(c.H, c.S, c.L)
}
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
if c1 == nil && c2 == nil {
return true
}
if c1 == nil || c2 == nil {
return false
}
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
}
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := hslColorFieldPattern.FindStringSubmatch(value)
if len(matches) != 4 {
return fmt.Errorf("invalid HSL color format: %s", value)
}
hue, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return err
}
if hue > hslHueMax {
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
}
saturation, err := strconv.ParseFloat(matches[2], 64)
if err != nil {
return err
}
if saturation > hslSaturationMax {
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
}
lightness, err := strconv.ParseFloat(matches[3], 64)
if err != nil {
return err
}
if lightness > hslLightnessMax {
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
}
c.H = hue
c.S = saturation
c.L = lightness
return nil
}
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
type durationField time.Duration
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
return fmt.Errorf("invalid duration format: %s", value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil {
return err
}
switch matches[2] {
case "s":
*d = durationField(time.Duration(duration) * time.Second)
case "m":
*d = durationField(time.Duration(duration) * time.Minute)
case "h":
*d = durationField(time.Duration(duration) * time.Hour)
case "d":
*d = durationField(time.Duration(duration) * 24 * time.Hour)
}
return nil
}
type customIconField struct {
URL template.URL
AutoInvert bool
}
func newCustomIconField(value string) customIconField {
const autoInvertPrefix = "auto-invert "
field := customIconField{}
if strings.HasPrefix(value, autoInvertPrefix) {
field.AutoInvert = true
value = strings.TrimPrefix(value, autoInvertPrefix)
}
prefix, icon, found := strings.Cut(value, ":")
if !found {
field.URL = template.URL(value)
return field
}
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
switch prefix {
case "si":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + basename + ".svg")
case "di":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
case "mdi":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
case "sh":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
default:
field.URL = template.URL(value)
}
return field
}
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
*i = newCustomIconField(value)
return nil
}
type proxyOptionsField struct {
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
Timeout durationField `yaml:"timeout"`
client *http.Client `yaml:"-"`
}
func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
type proxyOptionsFieldAlias proxyOptionsField
alias := (*proxyOptionsFieldAlias)(p)
var proxyURL string
if err := node.Decode(&proxyURL); err != nil {
if err := node.Decode(alias); err != nil {
return err
}
}
if proxyURL == "" && p.URL == "" {
return nil
}
if p.URL != "" {
proxyURL = p.URL
}
parsedUrl, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("parsing proxy URL: %v", err)
}
var timeout = defaultClientTimeout
if p.Timeout > 0 {
timeout = time.Duration(p.Timeout)
}
p.client = &http.Client{
Timeout: timeout,
Transport: &http.Transport{
Proxy: http.ProxyURL(parsedUrl),
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure},
},
}
return nil
}
type queryParametersField map[string][]string
func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
var decoded map[string]any
if err := node.Decode(&decoded); err != nil {
return err
}
*q = make(queryParametersField)
// TODO: refactor the duplication in the switch cases if any more types get added
for key, value := range decoded {
switch v := value.(type) {
case string:
(*q)[key] = []string{v}
case int, int8, int16, int32, int64, float32, float64:
(*q)[key] = []string{fmt.Sprintf("%v", v)}
case bool:
(*q)[key] = []string{fmt.Sprintf("%t", v)}
case []string:
(*q)[key] = append((*q)[key], v...)
case []any:
for _, item := range v {
switch item := item.(type) {
case string:
(*q)[key] = append((*q)[key], item)
case int, int8, int16, int32, int64, float32, float64:
(*q)[key] = append((*q)[key], fmt.Sprintf("%v", item))
case bool:
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
default:
return fmt.Errorf("invalid query parameter value type: %T", item)
}
}
default:
return fmt.Errorf("invalid query parameter value type: %T", value)
}
}
return nil
}
func (q *queryParametersField) toQueryString() string {
query := url.Values{}
for key, values := range *q {
for _, value := range values {
query.Add(key, value)
}
}
return query.Encode()
}
+572 -35
View File
@@ -1,43 +1,125 @@
package glance package glance
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io" "html/template"
"iter"
"log"
"maps"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
Server Server `yaml:"server"`
Theme Theme `yaml:"theme"` const (
Branding Branding `yaml:"branding"` configVarTypeEnv = "env"
Pages []Page `yaml:"pages"` configVarTypeSecret = "secret"
configVarTypeFileFromEnv = "readFileFromEnv"
)
type config struct {
Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
Proxied bool `yaml:"proxied"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
} `yaml:"server"`
Auth struct {
SecretKey string `yaml:"secret-key"`
Users map[string]*user `yaml:"users"`
} `yaml:"auth"`
Document struct {
Head template.HTML `yaml:"head"`
} `yaml:"document"`
Theme struct {
themeProperties `yaml:",inline"`
CustomCSSFile string `yaml:"custom-css-file"`
DisablePicker bool `yaml:"disable-picker"`
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
} `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"`
FaviconType string `yaml:"-"`
AppName string `yaml:"app-name"`
AppIconURL string `yaml:"app-icon-url"`
AppBackgroundColor string `yaml:"app-background-color"`
} `yaml:"branding"`
Pages []page `yaml:"pages"`
} }
func NewConfigFromYml(contents io.Reader) (*Config, error) { type user struct {
config := NewConfig() Password string `yaml:"password"`
PasswordHashString string `yaml:"password-hash"`
PasswordHash []byte `yaml:"-"`
}
contentBytes, err := io.ReadAll(contents) type page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
HeadWidgets widgets `yaml:"head-widgets"`
Columns []struct {
Size string `yaml:"size"`
Widgets widgets `yaml:"widgets"`
} `yaml:"columns"`
PrimaryColumnIndex int8 `yaml:"-"`
mu sync.Mutex `yaml:"-"`
}
func newConfigFromYAML(contents []byte) (*config, error) {
contents, err := parseConfigVariables(contents)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = yaml.Unmarshal(contentBytes, config) config := &config{}
config.Server.Port = 8080
err = yaml.Unmarshal(contents, config)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = configIsValid(config); err != nil { if err = isConfigStateValid(config); err != nil {
return nil, err return nil, err
} }
for p := range config.Pages { for p := range config.Pages {
for w := range config.Pages[p].HeadWidgets {
if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil {
return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w])
}
}
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,55 +128,510 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return config, nil return config, nil
} }
func NewConfig() *Config { var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
config := &Config{} var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
config.Server.Host = "" // Parses variables defined in the config such as:
config.Server.Port = 8080 // ${API_KEY} - gets replaced with the value of the API_KEY environment variable
// \${API_KEY} - escaped, gets used as is without the \ in the config
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
//
// TODO: don't match against commented out sections, not sure exactly how since
// variables can be placed anywhere and used to modify the YAML structure itself
func parseConfigVariables(contents []byte) ([]byte, error) {
var err error
return config replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
if err != nil {
return nil
}
groups := configVariablePattern.FindSubmatch(match)
if len(groups) != 4 {
// we can't handle this match, this shouldn't happen unless the number of groups
// in the regex has been changed without updating the below code
return match
}
prefix := string(groups[1])
if prefix == `\` {
if len(match) >= 2 {
return match[1:]
} else {
return nil
}
}
typeAsString, variableName := string(groups[2]), string(groups[3])
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
if localErr != nil {
err = fmt.Errorf("parsing variable: %v", localErr)
return nil
}
if returnOriginal {
return match
}
return []byte(prefix + parsedValue)
})
if err != nil {
return nil, err
}
return replaced, nil
} }
func configIsValid(config *Config) error { // When the bool return value is true, it indicates that the caller should use the original value
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
switch variableType {
case configVarTypeEnv:
if !envVariableNamePattern.MatchString(variableName) {
return "", true, nil
}
v, found := os.LookupEnv(variableName)
if !found {
return "", false, fmt.Errorf("environment variable %s not found", variableName)
}
return v, false, nil
case configVarTypeSecret:
secretPath := filepath.Join("/run/secrets", variableName)
secret, err := os.ReadFile(secretPath)
if err != nil {
return "", false, fmt.Errorf("reading secret file: %v", err)
}
return strings.TrimSpace(string(secret)), false, nil
case configVarTypeFileFromEnv:
if !envVariableNamePattern.MatchString(variableName) {
return "", true, nil
}
filePath, found := os.LookupEnv(variableName)
if !found {
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
}
if !filepath.IsAbs(filePath) {
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
}
fileContents, err := os.ReadFile(filePath)
if err != nil {
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
}
return strings.TrimSpace(string(fileContents)), false, nil
default:
return "", true, nil
}
}
func formatWidgetInitError(err error, w widget) error {
return fmt.Errorf("%s widget: %v", w.GetType(), err)
}
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
}
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
}
mainFileContents, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
}
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
}
mainFileDir := filepath.Dir(mainFileAbsPath)
if includes == nil {
includes = make(map[string]struct{})
}
var includesLastErr error
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if includesLastErr != nil {
return nil
}
matches := configIncludePattern.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
includes[includeFilePath] = struct{}{}
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
if err != nil {
includesLastErr = err
return nil
}
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{}
parseAndCompareBeforeCallback := 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
debouncedParseAndCompareBeforeCallback := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer.Reset(debounceDuration)
} else {
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
}
}
deleteLastInclude := func(filePath string) {
mu.Lock()
defer mu.Unlock()
fileAbsPath, _ := filepath.Abs(filePath)
delete(lastIncludes, fileAbsPath)
}
go func() {
for {
select {
case event, isOpen := <-watcher.Events:
if !isOpen {
return
}
if event.Has(fsnotify.Write) {
debouncedParseAndCompareBeforeCallback()
} else if event.Has(fsnotify.Rename) {
// on linux the file will no longer be watched after a rename, on windows
// it will continue to be watched with the new name but we have no access to
// the new name in this event in order to stop watching it manually and match the
// behavior in linux, may lead to weird unintended behaviors on windows as we're
// only handling renames from linux's perspective
// see https://github.com/fsnotify/fsnotify/issues/255
// remove the old file from our manually tracked includes, calling
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
// required after it triggers
deleteLastInclude(event.Name)
// wait for file to maybe get created again
// see https://github.com/glanceapp/glance/pull/358
for range 10 {
if _, err := os.Stat(event.Name); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
debouncedParseAndCompareBeforeCallback()
} else if event.Has(fsnotify.Remove) {
deleteLastInclude(event.Name)
debouncedParseAndCompareBeforeCallback()
}
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
}
// TODO: Refactor, we currently validate in two different places, this being
// one of them, which doesn't modify the data and only checks for logical errors
// and then again when creating the application which does modify the data and do
// further validation. Would be better if validation was done in a single place.
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
}
if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" {
return fmt.Errorf("secret-key must be set when users are configured")
}
for username := range config.Auth.Users {
if username == "" {
return fmt.Errorf("user has no name")
}
if len(username) < 3 {
return errors.New("usernames must be at least 3 characters")
}
user := config.Auth.Users[username]
if user.Password == "" {
if user.PasswordHashString == "" {
return fmt.Errorf("user %s must have a password or a password-hash set", username)
}
} else if len(user.Password) < 6 {
return fmt.Errorf("the password for %s must be at least 6 characters", username)
}
}
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 == "" { page := &config.Pages[i]
return fmt.Errorf("Page %d has no title", i+1)
if page.Title == "" {
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 page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
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 page.DesktopNavigationWidth != "" {
return fmt.Errorf("Page %d has no columns", i+1) if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
}
} }
if config.Pages[i].Width == "slim" { if len(page.Columns) == 0 {
if len(config.Pages[i].Columns) > 2 { return fmt.Errorf("page %d has no columns", i+1)
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1) }
if page.Width == "slim" {
if len(page.Columns) > 2 {
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(page.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)
} }
} }
columnSizesCount := make(map[string]int) columnSizesCount := make(map[string]int)
for j := range config.Pages[i].Columns { for j := range page.Columns {
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { column := &page.Columns[j]
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
if column.Size != "small" && column.Size != "full" {
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[page.Columns[j].Size]++
} }
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)
} }
} }
return nil return nil
} }
// Read-only way to store ordered maps from a YAML structure
type orderedYAMLMap[K comparable, V any] struct {
keys []K
data map[K]V
}
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
if len(keys) != len(values) {
return nil, fmt.Errorf("keys and values must have the same length")
}
om := &orderedYAMLMap[K, V]{
keys: make([]K, len(keys)),
data: make(map[K]V, len(keys)),
}
copy(om.keys, keys)
for i := range keys {
om.data[keys[i]] = values[i]
}
return om, nil
}
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for _, key := range om.keys {
value, ok := om.data[key]
if !ok {
continue
}
if !yield(key, value) {
return
}
}
}
}
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
value, ok := om.data[key]
return value, ok
}
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
merged := &orderedYAMLMap[K, V]{
keys: make([]K, 0, len(self.keys)+len(other.keys)),
data: make(map[K]V, len(self.data)+len(other.data)),
}
merged.keys = append(merged.keys, self.keys...)
maps.Copy(merged.data, self.data)
for _, key := range other.keys {
if _, exists := self.data[key]; !exists {
merged.keys = append(merged.keys, key)
}
}
maps.Copy(merged.data, other.data)
return merged
}
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
}
if len(node.Content)%2 != 0 {
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
}
om.keys = make([]K, len(node.Content)/2)
om.data = make(map[K]V, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
var key K
if err := keyNode.Decode(&key); err != nil {
return fmt.Errorf("orderedMap: decoding key: %v", err)
}
if _, ok := om.data[key]; ok {
return fmt.Errorf("orderedMap: duplicate key %v", key)
}
var value V
if err := valueNode.Decode(&value); err != nil {
return fmt.Errorf("orderedMap: decoding value: %v", err)
}
(*om).keys[i/2] = key
(*om).data[key] = value
}
return nil
}
+209
View File
@@ -0,0 +1,209 @@
package glance
import (
"context"
"fmt"
"io"
"net"
"net/http"
"runtime"
"strings"
"sync"
"time"
)
const httpTestRequestTimeout = 15 * 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 testHttpRequestWithHeaders("GET", "https://www.reddit.com/search.json", map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
}, 200)
},
},
{
name: "fetch data from Yahoo finance API",
fn: func() (string, error) {
return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
}, 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: " + ternary(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",
ternary(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 := defaultHTTPClient.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
}
+151
View File
@@ -0,0 +1,151 @@
package glance
import (
"bytes"
"crypto/md5"
"embed"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log"
"path/filepath"
"regexp"
"strconv"
"strings"
"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")
func readAllFromStaticFS(path string) ([]byte, error) {
// For some reason fs.FS only works with forward slashes, so in case we're
// running on Windows or pass paths with backslashes we need to replace them.
path = strings.ReplaceAll(path, "\\", "/")
file, err := staticFS.Open(path)
if err != nil {
return nil, err
}
return io.ReadAll(file)
}
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
}
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
// Yes, we bundle at runtime, give comptime pls
var bundledCSSContents = func() []byte {
const mainFilePath = "css/main.css"
var recursiveParseImports func(path string, depth int) ([]byte, error)
recursiveParseImports = func(path string, depth int) ([]byte, error) {
if depth > 20 {
return nil, errors.New("maximum import depth reached, is one of your imports circular?")
}
mainFileContents, err := readAllFromStaticFS(path)
if err != nil {
return nil, err
}
// Normalize line endings, otherwise the \r's make the regex not match
mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
mainFileDir := filepath.Dir(path)
var importLastErr error
parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if importLastErr != nil {
return nil
}
matches := cssImportPattern.FindSubmatch(match)
if len(matches) != 2 {
importLastErr = fmt.Errorf(
"import didn't return expected number of capture groups: %s, expected 2, got %d",
match, len(matches),
)
return nil
}
importFilePath := filepath.Join(mainFileDir, string(matches[1]))
importContents, err := recursiveParseImports(importFilePath, depth+1)
if err != nil {
importLastErr = err
return nil
}
return importContents
})
if importLastErr != nil {
return nil, importLastErr
}
return parsed, nil
}
contents, err := recursiveParseImports(mainFilePath, 0)
if err != nil {
panic(fmt.Sprintf("building CSS bundle: %v", err))
}
// We could strip a bunch more unnecessary characters, but the biggest
// win comes from removing the whitespace at the beginning of lines
// since that's at least 4 bytes per property, which yielded a ~20% reduction.
contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
return contents
}()
+388 -177
View File
@@ -3,99 +3,265 @@ package glance
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"fmt" "fmt"
"html/template" "log"
"log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp" "slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/glanceapp/glance/internal/assets" "golang.org/x/crypto/bcrypt"
"github.com/glanceapp/glance/internal/widget"
) )
var buildVersion = "dev" var (
pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html")
pageContentTemplate = mustParseTemplate("page-content.html")
manifestTemplate = mustParseTemplate("manifest.json")
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
type Application struct { var reservedPageSlugs = []string{"login", "logout"}
Version string
Config Config type application struct {
slugToPage map[string]*Page Version string
widgetByID map[uint64]widget.Widget CreatedAt time.Time
Config config
parsedManifest []byte
slugToPage map[string]*page
widgetByID map[uint64]widget
RequiresAuth bool
authSecretKey []byte
usernameHashToUsername map[string]string
authAttemptsMu sync.Mutex
failedAuthAttempts map[string]*failedAuthAttempt
} }
type Theme struct { func newApplication(c *config) (*application, error) {
BackgroundColor *widget.HSLColorField `yaml:"background-color"` app := &application{
PrimaryColor *widget.HSLColorField `yaml:"primary-color"` Version: buildVersion,
PositiveColor *widget.HSLColorField `yaml:"positive-color"` CreatedAt: time.Now(),
NegativeColor *widget.HSLColorField `yaml:"negative-color"` Config: *c,
Light bool `yaml:"light"` slugToPage: make(map[string]*page),
ContrastMultiplier float32 `yaml:"contrast-multiplier"` widgetByID: make(map[uint64]widget),
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` }
CustomCSSFile string `yaml:"custom-css-file"` config := &app.Config
//
// Init auth
//
if len(config.Auth.Users) > 0 {
secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey)
if err != nil {
return nil, fmt.Errorf("decoding secret-key: %v", err)
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH)
}
app.usernameHashToUsername = make(map[string]string)
app.failedAuthAttempts = make(map[string]*failedAuthAttempt)
app.RequiresAuth = true
for username := range config.Auth.Users {
user := config.Auth.Users[username]
usernameHash, err := computeUsernameHash(username, secretBytes)
if err != nil {
return nil, fmt.Errorf("computing username hash for user %s: %v", username, err)
}
app.usernameHashToUsername[string(usernameHash)] = username
if user.PasswordHashString != "" {
user.PasswordHash = []byte(user.PasswordHashString)
user.PasswordHashString = ""
} else {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password for user %s: %v", username, err)
}
user.Password = ""
user.PasswordHash = hashedPassword
}
}
app.authSecretKey = secretBytes
}
//
// Init themes
//
if !config.Theme.DisablePicker {
themeKeys := make([]string, 0, 2)
themeProps := make([]*themeProperties, 0, 2)
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
themeKeys = append(themeKeys, "default-dark")
themeProps = append(themeProps, &themeProperties{})
}
themeKeys = append(themeKeys, "default-light")
themeProps = append(themeProps, &themeProperties{
Light: true,
BackgroundColor: &hslColorField{240, 13, 95},
PrimaryColor: &hslColorField{230, 100, 30},
NegativeColor: &hslColorField{0, 70, 50},
ContrastMultiplier: 1.3,
TextSaturationMultiplier: 0.5,
})
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
if err != nil {
return nil, fmt.Errorf("creating theme presets: %v", err)
}
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
for key, properties := range config.Theme.Presets.Items() {
properties.Key = key
if err := properties.init(); err != nil {
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
}
}
}
config.Theme.Key = "default"
if err := config.Theme.init(); err != nil {
return nil, fmt.Errorf("initializing default theme: %v", err)
}
//
// Init pages
//
app.slugToPage[""] = &config.Pages[0]
providers := &widgetProviders{
assetResolver: app.StaticAssetPath,
}
for p := range config.Pages {
page := &config.Pages[p]
page.PrimaryColumnIndex = -1
if page.Slug == "" {
page.Slug = titleToSlug(page.Title)
}
if slices.Contains(reservedPageSlugs, page.Slug) {
return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug)
}
app.slugToPage[page.Slug] = page
if page.Width == "default" {
page.Width = ""
}
if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" {
page.DesktopNavigationWidth = page.Width
}
for i := range page.HeadWidgets {
widget := page.HeadWidgets[i]
app.widgetByID[widget.GetID()] = widget
widget.setProviders(providers)
}
for c := range page.Columns {
column := &page.Columns[c]
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
page.PrimaryColumnIndex = int8(c)
}
for w := range column.Widgets {
widget := column.Widgets[w]
app.widgetByID[widget.GetID()] = widget
widget.setProviders(providers)
}
}
}
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
config.Branding.FaviconURL = ternary(
config.Branding.FaviconURL == "",
app.StaticAssetPath("favicon.svg"),
app.resolveUserDefinedAssetPath(config.Branding.FaviconURL),
)
config.Branding.FaviconType = ternary(
strings.HasSuffix(config.Branding.FaviconURL, ".svg"),
"image/svg+xml",
"image/png",
)
if config.Branding.AppName == "" {
config.Branding.AppName = "Glance"
}
if config.Branding.AppIconURL == "" {
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
}
if config.Branding.AppBackgroundColor == "" {
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
}
manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app})
if err != nil {
return nil, fmt.Errorf("parsing manifest.json: %v", err)
}
app.parsedManifest = []byte(manifest)
return app, nil
} }
type Server struct { func (p *page) updateOutdatedWidgets() {
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"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
}
func (p *Page) UpdateOutdatedWidgets() {
now := time.Now() now := time.Now()
var wg sync.WaitGroup var wg sync.WaitGroup
context := context.Background() context := context.Background()
for w := range p.HeadWidgets {
widget := p.HeadWidgets[w]
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.update(context)
}()
}
for c := range p.Columns { for c := range p.Columns {
for w := range p.Columns[c].Widgets { for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w] widget := p.Columns[c].Widgets[w]
if !widget.RequiresUpdate(&now) { if !widget.requiresUpdate(&now) {
continue continue
} }
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
widget.Update(context) widget.update(context)
}() }()
} }
} }
@@ -103,16 +269,7 @@ func (p *Page) UpdateOutdatedWidgets() {
wg.Wait() wg.Wait()
} }
// TODO: fix, currently very simple, lots of uncovered edge cases func (a *application) resolveUserDefinedAssetPath(path string) string {
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/") { if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path return a.Config.Server.BaseURL + path
} }
@@ -120,74 +277,51 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
return path return path
} }
func NewApplication(config *Config) (*Application, error) { type templateRequestData struct {
if len(config.Pages) == 0 { Theme *themeProperties
return nil, fmt.Errorf("no pages configured") }
}
app := &Application{ type templateData struct {
Version: buildVersion, App *application
Config: *config, Page *page
slugToPage: make(map[string]*Page), Request templateRequestData
widgetByID: make(map[uint64]widget.Widget), }
}
app.Config.Server.AssetsHash = assets.PublicFSHash func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
app.slugToPage[""] = &config.Pages[0] theme := &a.Config.Theme.themeProperties
providers := &widget.Providers{ if !a.Config.Theme.DisablePicker {
AssetResolver: app.AssetPath, selectedTheme, err := r.Cookie("theme")
} if err == nil {
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
for p := range config.Pages { if exists {
if config.Pages[p].Slug == "" { theme = preset
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
}
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w]
app.widgetByID[widget.GetID()] = widget
widget.SetProviders(providers)
} }
} }
} }
config = &app.Config data.Theme = theme
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
}
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil
} }
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { 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{ if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
return
}
data := templateData{
Page: page, Page: page,
App: a, App: a,
} }
a.populateTemplateRequestData(&data.Request, r)
var responseBytes bytes.Buffer var responseBytes bytes.Buffer
err := assets.PageTemplate.Execute(&responseBytes, pageData) err := pageTemplate.Execute(&responseBytes, data)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@@ -197,11 +331,14 @@ 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
}
if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
return return
} }
@@ -209,12 +346,16 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
Page: page, Page: page,
} }
page.mu.Lock() var err error
defer page.mu.Unlock()
page.UpdateOutdatedWidgets()
var responseBytes bytes.Buffer var responseBytes bytes.Buffer
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
func() {
page.mu.Lock()
defer page.mu.Unlock()
page.updateOutdatedWidgets()
err = pageContentTemplate.Execute(&responseBytes, pageData)
}()
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -225,71 +366,125 @@ 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) addressOfRequest(r *http.Request) string {
remoteAddrWithoutPort := func() string {
for i := len(r.RemoteAddr) - 1; i >= 0; i-- {
if r.RemoteAddr[i] == ':' {
return r.RemoteAddr[:i]
}
}
return r.RemoteAddr
}
if !a.Config.Server.Proxied {
return remoteAddrWithoutPort()
}
// This should probably be configurable or look for multiple headers, not just this one
forwardedFor := r.Header.Get("X-Forwarded-For")
if forwardedFor == "" {
return remoteAddrWithoutPort()
}
ips := strings.Split(forwardedFor, ",")
if len(ips) == 0 || ips[0] == "" {
return remoteAddrWithoutPort()
}
return ips[0]
}
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) // TODO: this requires a rework of the widget update logic so that rather
// than locking the entire page we lock individual widgets
w.WriteHeader(http.StatusNotImplemented)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // widgetValue := r.PathValue("widget")
// 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()))) // widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
server.ServeHTTP(w, r) // if err != nil {
}) // a.handleNotFound(w, r)
// return
// }
// widget, exists := a.widgetByID[widgetID]
// if !exists {
// a.handleNotFound(w, r)
// return
// }
// widget.handleRequest(w, r)
} }
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) { func (a *application) StaticAssetPath(asset string) string {
widgetValue := r.PathValue("widget") return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.HandleNotFound(w, r)
return
}
widget, exists := a.widgetByID[widgetID]
if !exists {
a.HandleNotFound(w, r)
return
}
widget.HandleRequest(w, r)
} }
func (a *Application) AssetPath(asset string) string { func (a *application) VersionedAssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset return a.Config.Server.BaseURL + asset +
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
} }
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 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)
if !a.Config.Theme.DisablePicker {
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
}
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt)
}
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), STATIC_ASSETS_CACHE_DURATION),
),
) )
assetCacheControlValue := fmt.Sprintf(
"public, max-age=%d",
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
)
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "text/css; charset=utf-8")
w.Write(bundledCSSContents)
})
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "application/json")
w.Write(a.parsedManifest)
})
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))
} }
@@ -298,8 +493,24 @@ 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) 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
} }
+195 -22
View File
@@ -2,45 +2,218 @@ package glance
import ( import (
"fmt" "fmt"
"io"
"log"
"net/http"
"os" "os"
"golang.org/x/crypto/bcrypt"
) )
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 cliIntentVersionPrint:
if err != nil { fmt.Println(buildVersion)
fmt.Printf("failed opening config file: %v\n", err) case cliIntentServe:
return 1 // remove in v0.10.0
} if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
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 cliIntentSensorsPrint:
return cliSensorsPrint()
case cliIntentMountpointInfo:
return cliMountpointInfo(options.args[1])
case cliIntentDiagnose:
runDiagnostic()
case cliIntentSecretMake:
key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
if err != nil {
fmt.Printf("Failed to make secret key: %v\n", err)
return 1
}
fmt.Println(key)
case cliIntentPasswordHash:
password := options.args[1]
if password == "" {
fmt.Println("Password cannot be empty")
return 1
}
if len(password) < 6 {
fmt.Println("Password must be at least 6 characters long")
return 1
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Printf("Failed to hash password: %v\n", err)
return 1
}
fmt.Println(string(hashedPassword))
} }
return 0 return 0
} }
func serveApp(configPath string) error {
// TODO: refactor if this gets any more complex, the current implementation is
// difficult to reason about due to all of the callbacks and simultaneous operations,
// use a single goroutine and a channel to initiate synchronous changes to the server
exitChannel := make(chan struct{})
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
}
app, err := newApplication(config)
if err != nil {
log.Printf("Failed to create application: %v", err)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
}
if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
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)
fmt.Println("!!! WARNING !!!")
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.")
fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md 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
}
+44
View File
@@ -0,0 +1,44 @@
package glance
import "sync"
type SingleflightCall[T any] struct {
done chan struct{}
val T
err error
}
type Singleflight[T any] struct {
fn func() (T, error)
mu sync.Mutex
current *SingleflightCall[T]
}
func (s *Singleflight[T]) Do() (T, error) {
s.mu.Lock()
if s.current != nil {
// Wait for the current call to finish and return its result
c := s.current
s.mu.Unlock()
<-c.done
return c.val, c.err
}
// Initiate a new call
c := &SingleflightCall[T]{done: make(chan struct{})}
s.current = c
s.mu.Unlock()
c.val, c.err = s.fn()
s.mu.Lock()
s.current = nil
s.mu.Unlock()
close(c.done)
return c.val, c.err
}
func NewSingleflight[T any](fn func() (T, error)) func() (T, error) {
sf := &Singleflight[T]{fn: fn}
return sf.Do
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -0,0 +1,19 @@
.forum-post-list-thumbnail {
flex-shrink: 0;
width: 6rem;
height: 4.1rem;
border-radius: var(--border-radius);
object-fit: cover;
border: 1px solid var(--color-separator);
margin-top: 0.1rem;
}
.forum-post-tags-container {
transform: translateY(-0.15rem);
}
@container widget (max-width: 550px) {
.forum-post-autohide {
display: none;
}
}
+155
View File
@@ -0,0 +1,155 @@
.login-bounds {
max-width: 500px;
padding: 0 2rem;
}
.form-label {
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.form-input {
transition: border-color .2s;
}
.form-input input {
border: 0;
background: none;
width: 100%;
height: 5.2rem;
font: inherit;
outline: none;
color: var(--color-text-highlight);
}
.form-input-icon {
width: 2rem;
height: 2rem;
margin-top: -0.1rem;
opacity: 0.5;
}
.form-input input[type="password"] {
letter-spacing: 0.3rem;
font-size: 0.9em;
}
.form-input input[type="password"]::placeholder {
letter-spacing: 0;
font-size: var(--font-size-base);
}
.form-input:hover {
border-color: var(--color-progress-border);
}
.form-input:focus-within {
border-color: var(--color-primary);
transition-duration: .7s;
}
.login-button {
width: 100%;
display: block;
padding: 1rem;
background: none;
border: 1px solid var(--color-text-subdue);
border-radius: var(--border-radius);
color: var(--color-text-paragraph);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
display: flex;
gap: .5rem;
align-items: center;
justify-content: center;
transition: all .3s, margin-top 0s;
margin-top: 3rem;
}
.login-button:not(:disabled) {
box-shadow: 0 0 10px 1px var(--color-separator);
}
.login-error-message:not(:empty) + .login-button {
margin-top: 2rem;
}
.login-button:focus, .login-button:hover {
outline: none;
border-color: var(--color-primary);
color: var(--color-primary);
}
.login-button:disabled {
border-color: var(--color-separator);
color: var(--color-text-subdue);
cursor: not-allowed;
}
.login-button svg {
width: 1.7rem;
height: 1.7rem;
transition: transform .2s;
}
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
transform: translateX(.5rem);
}
.animate-entrance {
animation: fieldReveal 0.7s backwards;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.animate-entrance:nth-child(1) { animation-delay: .1s; }
.animate-entrance:nth-child(2) { animation-delay: .2s; }
.animate-entrance:nth-child(4) { animation-delay: .3s; }
@keyframes fieldReveal {
from {
opacity: 0.0001;
transform: translateY(4rem);
}
}
.login-error-message {
color: var(--color-negative);
font-size: var(--font-size-base);
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
position: relative;
margin-top: 2rem;
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes errorMessageEntrance {
from {
opacity: 0;
transform: scale(1.1);
}
}
.login-error-message:empty {
display: none;
}
.login-error-message::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--border-radius);
background: var(--color-negative);
opacity: 0.05;
z-index: -1;
}
.footer {
animation-delay: .4s;
animation-duration: 1s;
}
.toggle-password-visibility {
background: none;
border: none;
cursor: pointer;
}
+66
View File
@@ -0,0 +1,66 @@
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
:root {
font-size: 10px;
--scheme: ;
--bgh: 240;
--bgs: 8%;
--bgl: 9%;
--bghs: var(--bgh), var(--bgs);
--cm: 1;
--tsm: 1;
--widget-gap: 23px;
--widget-content-vertical-padding: 15px;
--widget-content-horizontal-padding: 17px;
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
--content-bounds-padding: 15px;
--border-radius: 5px;
--mobile-navigation-height: 50px;
--color-primary: hsl(43, 50%, 70%);
--color-positive: var(--color-primary);
--color-negative: hsl(0, 70%, 70%);
--color-background: hsl(var(--bghs), var(--bgl));
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 26% * var(--cm))));
--color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * var(--cm))));
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
--font-size-h1: 1.7rem;
--font-size-h2: 1.6rem;
--font-size-h3: 1.5rem;
--font-size-h4: 1.4rem;
--font-size-base: 1.3rem;
--font-size-h5: 1.2rem;
--font-size-h6: 1.1rem;
}
/* Do not change the order of the below imports unless you know what you're doing */
@import "site.css";
@import "widgets.css";
@import "popover.css";
@import "utils.css";
@import "mobile.css";
+235
View File
@@ -0,0 +1,235 @@
@media (max-width: 1190px) {
.header-container {
display: none;
}
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h3);
}
.page-column-small {
width: 100%;
flex-shrink: 1;
}
.page-column {
display: none;
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.page-columns-transitioned .page-column {
animation-duration: .3s;
}
@keyframes columnEntrance {
from {
opacity: 0;
transform: scaleX(0.95);
}
}
.mobile-navigation-offset {
height: var(--mobile-navigation-height);
flex-shrink: 0;
}
.mobile-navigation {
display: block;
position: fixed;
bottom: 0;
transform: translateY(calc(100% - var(--mobile-navigation-height)));
left: var(--content-bounds-padding);
right: var(--content-bounds-padding);
z-index: 11;
background-color: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-bottom: 0;
border-radius: var(--border-radius) var(--border-radius) 0 0;
transition: transform .3s;
}
.mobile-navigation-actions > * {
padding-block: 1.1rem;
padding-inline: var(--content-bounds-padding);
cursor: pointer;
transition: background-color 50ms;
}
.mobile-navigation-actions > *:active {
background-color: var(--color-widget-background-highlight);
}
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
--spacing: 7px;
color: var(--color-primary);
height: 2px;
}
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) {
transform: translateY(0);
}
.mobile-navigation-page-links {
border-top: 1px solid var(--color-widget-content-border);
border-bottom: 1px solid var(--color-widget-content-border);
padding: 20px var(--content-bounds-padding);
display: flex;
align-items: center;
overflow-x: auto;
scrollbar-width: thin;
gap: 2.5rem;
}
.mobile-navigation-icons {
display: flex;
justify-content: space-around;
align-items: center;
}
body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1),
body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2),
body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) {
display: block;
}
.mobile-navigation-label {
display: flex;
flex: 1;
max-width: 50px;
height: var(--mobile-navigation-height);
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 15px;
line-height: var(--mobile-navigation-height);
}
.mobile-navigation-pill {
display: block;
background: var(--color-text-base);
height: 10px;
width: 10px;
border-radius: 10px;
transition: width .3s, background-color .3s;
}
.mobile-navigation-label:hover > .mobile-navigation-pill {
background-color: var(--color-text-highlight);
}
.mobile-navigation-label:hover {
color: var(--color-text-highlight);
}
.mobile-navigation-input:checked + .mobile-navigation-pill {
background: var(--color-primary);
width: 30px;
}
.mobile-navigation-input, .mobile-navigation-page-links-input {
display: none;
}
.hamburger-icon {
--spacing: 4px;
width: 1em;
height: 1px;
background-color: currentColor;
transition: color .3s, box-shadow .3s;
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
}
.expand-toggle-button.container-expanded {
bottom: var(--mobile-navigation-height);
}
.cards-grid + .expand-toggle-button.container-expanded {
/* hides content that peeks through the rounded borders of the mobile navigation */
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
}
.weather-column-rain::before {
background-size: 7px 7px;
}
.ios .search-input {
/* so that iOS Safari does not zoom the page when the input is focused */
font-size: 16px;
}
}
@media (max-width: 1190px) and (display-mode: standalone) {
:root {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
}
.ios .body-content {
height: 100dvh;
}
.expand-toggle-button.container-expanded {
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
}
.mobile-navigation {
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
padding-bottom: var(--safe-area-inset-bottom);
}
.mobile-navigation-icons {
padding-bottom: var(--safe-area-inset-bottom);
transition: padding-bottom .3s;
}
.mobile-navigation-offset {
height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
}
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
padding-bottom: 0;
}
}
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top, 0);
}
}
@media (max-width: 550px) {
:root {
font-size: 9.4px;
--widget-gap: 15px;
--widget-content-vertical-padding: 10px;
--widget-content-horizontal-padding: 10px;
--content-bounds-padding: 10px;
}
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.row-reverse-on-mobile {
flex-direction: row-reverse;
}
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
display: none
}
.mobile-reachability-header {
display: block;
font-size: 3rem;
padding: 10vh 1rem;
text-align: center;
color: var(--color-text-highlight);
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.rss-detailed-thumbnail > * {
height: 6rem;
}
.rss-detailed-description {
line-clamp: 3;
-webkit-line-clamp: 3;
}
}
+65
View File
@@ -0,0 +1,65 @@
.popover-container, [data-popover-html] {
display: none;
}
.popover-container {
--triangle-size: 10px;
--triangle-offset: 50%;
--triangle-margin: calc(var(--triangle-size) + 3px);
--entrance-y-offset: 8px;
--entrance-direction: calc(var(--entrance-y-offset) * -1);
z-index: 20;
position: absolute;
padding-top: var(--triangle-margin);
padding-inline: var(--content-bounds-padding);
}
.popover-container.position-above {
--entrance-direction: var(--entrance-y-offset);
padding-top: 0;
padding-bottom: var(--triangle-margin);
}
.popover-frame {
--shadow-properties: 0 15px 20px -10px;
--shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
position: relative;
padding: 10px;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 5px;
animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: var(--shadow-properties) var(--shadow-color);
}
.popover-frame::before {
content: '';
position: absolute;
width: var(--triangle-size);
height: var(--triangle-size);
transform: rotate(45deg);
background-color: var(--color-popover-background);
border-top-left-radius: 2px;
border-left: 1px solid var(--color-popover-border);
border-top: 1px solid var(--color-popover-border);
left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
top: calc(var(--triangle-size) / 2 * -1 - 1px);
}
.popover-container.position-above .popover-frame::before {
transform: rotate(-135deg);
top: auto;
bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
}
.popover-container.position-above .popover-frame {
--shadow-properties: 0 10px 20px -10px;
}
@keyframes popoverFrameEntrance {
from {
opacity: 0;
transform: translateY(var(--entrance-direction));
}
}
+398
View File
@@ -0,0 +1,398 @@
:root[data-scheme=light] {
--scheme: 100% -;
}
.page {
height: 100%;
padding-block: var(--widget-gap);
}
.page-content, .page.content-ready .page-loading-container {
display: none;
}
.page.content-ready > .page-content {
display: block;
animation: pageContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h4);
}
.page-column-full .size-title-dynamic {
font-size: var(--font-size-h3);
}
pre {
font: inherit;
}
input[type="text"] {
width: 100%;
border: 0;
background: none;
font: inherit;
color: inherit;
}
button {
font: inherit;
border: 0;
cursor: pointer;
background: none;
color: inherit;
}
::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight);
}
::-webkit-scrollbar-thumb {
background: var(--color-text-subdue);
border-radius: var(--border-radius);
}
::-webkit-scrollbar {
background: var(--color-background);
height: 5px;
width: 10px;
}
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 0.1rem;
border-radius: var(--border-radius);
}
*, *::before, *::after {
box-sizing: border-box;
}
* {
padding: 0;
margin: 0;
}
hr {
border: 0;
height: 1px;
background-color: var(--color-separator);
}
img, svg {
display: block;
max-width: 100%;
}
img[loading=lazy].loaded:not(.finished-transition) {
transition: opacity .4s;
}
img[loading=lazy].cached:not(.finished-transition) {
transition: none;
}
img[loading=lazy]:not(.loaded, .cached) {
opacity: 0;
}
html {
scrollbar-color: var(--color-text-subdue) transparent;
scroll-behavior: smooth;
}
html, body, .body-content {
height: 100%;
}
h1, h2, h3, h4, h5 {
font: inherit;
}
a {
text-decoration: none;
color: inherit;
overflow-wrap: break-word;
}
ul {
list-style: none;
}
body {
font-size: 1.3rem;
font-family: 'JetBrains Mono', monospace;
font-variant-ligatures: none;
line-height: 1.6;
color: var(--color-text-base);
background-color: var(--color-background);
overflow-y: scroll;
}
.page-column-small {
width: 300px;
flex-shrink: 0;
}
.page-column-full {
width: 100%;
min-width: 0;
}
.page-columns {
display: flex;
gap: var(--widget-gap);
}
@keyframes pageContentEntrance {
from {
opacity: 0;
transform: translateY(10px);
}
}
.page-loading-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
font-size: 2rem;
}
.page-loading-container > .loading-icon {
translate: 0 -250%;
}
@keyframes loadingContainerEntrance {
from {
/* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */
/* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */
opacity: 0.001;
}
}
.loading-icon {
min-width: 1.5em;
width: 1.5em;
height: 1.5em;
border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%)));
border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
border-radius: 50%;
animation: loadingIconSpin 800ms infinite linear;
}
@keyframes loadingIconSpin {
to {
transform: rotate(360deg);
}
}
.notice-icon {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
}
.notice-icon-major {
background: var(--color-negative);
}
.notice-icon-minor {
border: 1px solid var(--color-negative);
}
kbd {
font: inherit;
padding: 0.1rem 0.8rem;
border-radius: var(--border-radius);
border: 2px solid var(--color-widget-background-highlight);
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
user-select: none;
transition: transform .1s, box-shadow .1s;
font-size: var(--font-size-h5);
cursor: pointer;
}
kbd:active {
transform: translateY(2px);
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
.content-bounds {
max-width: 1600px;
width: 100%;
margin-inline: auto;
padding: 0 var(--content-bounds-padding);
}
.content-bounds-wide {
max-width: 1920px;
}
.content-bounds-slim {
max-width: 1100px;
}
.page.center-vertically {
display: flex;
justify-content: center;
flex-direction: column;
}
.header-container {
margin-top: calc(var(--widget-gap) / 2);
--header-height: 45px;
--header-items-gap: 2.5rem;
}
.header {
display: flex;
height: var(--header-height);
gap: var(--header-items-gap);
}
.logo {
height: 100%;
flex-shrink: 0;
line-height: var(--header-height);
font-size: 2rem;
color: var(--color-text-highlight);
border-right: 1px solid var(--color-widget-content-border);
padding-right: var(--widget-content-horizontal-padding);
}
.logo:has(img, svg) {
display: flex;
align-items: center;
}
.logo img {
max-height: 2.7rem;
}
.nav {
overflow-x: auto;
min-width: 0;
height: 100%;
gap: var(--header-items-gap);
}
.nav .nav-item {
line-height: var(--header-height);
}
.footer {
padding-bottom: calc(var(--widget-gap) * 1.5);
padding-top: calc(var(--widget-gap) / 2);
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
}
.mobile-navigation, .mobile-reachability-header {
display: none;
}
.nav-item {
display: block;
height: 100%;
border-bottom: 2px solid transparent;
transition: color .3s, border-color .3s;
font-size: var(--font-size-h3);
flex-shrink: 0;
}
.nav-item:not(.nav-item-current):hover {
border-bottom-color: var(--color-text-subdue);
color: var(--color-text-highlight);
}
.nav-item.nav-item-current {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.logout-button {
width: 2rem;
height: 2rem;
stroke: var(--color-text-subdue);
transition: stroke .2s;
}
.logout-button:hover, .logout-button:focus {
stroke: var(--color-text-highlight);
}
.theme-choices {
--presets-per-row: 2;
display: grid;
grid-template-columns: repeat(var(--presets-per-row), 1fr);
align-items: center;
gap: 1.35rem;
}
.theme-choices:has(> :nth-child(3)) {
--presets-per-row: 3;
}
.theme-preset {
background-color: var(--color);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 2rem;
padding-inline: 0.5rem;
border-radius: 0.3rem;
border: none;
cursor: pointer;
position: relative;
}
.theme-choices .theme-preset::before {
content: '';
position: absolute;
inset: -.4rem;
border-radius: .7rem;
border: 2px solid transparent;
transition: border-color .3s;
}
.theme-choices .theme-preset:hover::before {
border-color: var(--color-text-subdue);
}
.theme-choices .theme-preset.current::before {
border-color: var(--color-text-base);
}
.theme-preset-light {
gap: 0.3rem;
height: 1.8rem;
}
.theme-color {
background-color: var(--color);
width: 0.9rem;
height: 0.9rem;
border-radius: 0.2rem;
}
.theme-preset-light .theme-color {
width: 1rem;
height: 1rem;
border-radius: 0.3rem;
}
.current-theme-preview {
opacity: 0.4;
transition: opacity .3s;
}
.theme-picker.popover-active .current-theme-preview, .theme-picker:hover {
opacity: 1;
}
+637
View File
@@ -0,0 +1,637 @@
.masonry {
display: flex;
gap: var(--widget-gap);
}
.masonry-column {
flex: 1;
display: flex;
flex-direction: column;
}
.widget-small-content-bounds {
max-width: 350px;
margin: 0 auto;
}
.visually-hidden {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.list-horizontal-text {
display: flex;
list-style: none;
flex-wrap: wrap;
align-items: center;
}
.list-horizontal-text > *:not(:last-child)::after {
content: '•' / "";
color: var(--color-text-subdue);
margin: 0 0.4rem;
position: relative;
top: 0.1rem;
}
.summary {
width: 100%;
cursor: pointer;
word-spacing: -0.18em;
user-select: none;
list-style: none;
position: relative;
display: flex;
z-index: 1;
}
.summary::-webkit-details-marker {
display: none;
}
.details[open] .summary {
margin-bottom: .8rem;
}
.summary::before {
content: "";
position: absolute;
inset: -.3rem -.8rem;
border-radius: var(--border-radius);
background-color: var(--color-widget-background-highlight);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.details[open] .summary::before, .summary:hover::before {
opacity: 1;
}
.details:not([open]) .list-with-transition {
display: none;
}
.summary::after {
content: "◀" / "";
font-size: 1.2em;
position: absolute;
top: 0;
bottom: 0;
line-height: 1.3em;
right: 0;
transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
}
details[open] .summary::after {
rotate: -90deg;
}
/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
.dynamic-columns {
--list-half-gap: 0.5rem;
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
display: grid;
grid-template-columns: repeat(var(--columns-per-row), 1fr);
}
.dynamic-columns > * {
padding-left: var(--widget-content-horizontal-padding);
border-left: 1px solid var(--color-separator);
min-width: 0;
}
.dynamic-columns > *:first-child {
padding-top: 0;
border-top: none;
border-left: none;
}
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
@container widget (max-width: 599px) {
.dynamic-columns { gap: 0; }
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.dynamic-columns > * {
border-left: none;
padding-left: 0;
}
.dynamic-columns > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2);
}
.dynamic-columns.list-with-separator > *:not(:first-child) {
margin-top: var(--list-half-gap);
border-top: 1px solid var(--color-separator);
padding-top: var(--list-half-gap);
}
}
@container widget (min-width: 600px) and (max-width: 849px) {
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
.dynamic-columns > :nth-child(2n-1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 850px) and (max-width: 1249px) {
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
.dynamic-columns > :nth-child(3n+1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 1250px) and (max-width: 1499px) {
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
.dynamic-columns > :nth-child(4n+1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 1500px) {
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
.dynamic-columns > :nth-child(5n+1) {
border-left: none;
padding-left: 0;
}
}
.cards-vertical {
flex-direction: column;
}
.cards-horizontal {
--cards-per-row: 6.5;
}
.cards-horizontal, .cards-vertical {
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
display: flex;
gap: var(--cards-gap);
}
.card {
display: flex;
flex-direction: column;
}
.cards-horizontal .card {
flex-shrink: 0;
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
}
.cards-grid .card {
min-width: 0;
}
.cards-horizontal {
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 1rem;
}
.cards-grid {
--cards-per-row: 6;
display: grid;
grid-template-columns: repeat(var(--cards-per-row), 1fr);
gap: calc(var(--widget-content-vertical-padding) * 0.7);
}
@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } }
@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } }
@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
@container widget (max-width: 850px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
.text-truncate,
.single-line-titles .title
{
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.single-line-titles .title {
display: block;
}
.text-truncate-2-lines, .text-truncate-3-lines {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
.visited-indicator:not(.text-truncate)::after,
.visited-indicator.text-truncate::before {
content: '↗' / "";
margin-left: 0.5em;
display: inline-block;
position: relative;
top: 0.15em;
color: var(--color-text-base);
}
.visited-indicator.text-truncate {
direction: rtl;
text-align: left;
}
.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after {
color: var(--color-primary);
}
.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
.list > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2);
}
.list.list-with-separator > *:not(:first-child) {
margin-top: var(--list-half-gap);
border-top: 1px solid var(--color-separator);
padding-top: var(--list-half-gap);
}
.collapsible-container:not(.container-expanded) > .collapsible-item {
display: none;
}
.collapsible-item {
animation: collapsibleItemReveal .25s backwards;
}
@keyframes collapsibleItemReveal {
from {
opacity: 0;
transform: translateY(10px);
}
}
.expand-toggle-button {
font: inherit;
border: 0;
cursor: pointer;
display: block;
width: 100%;
text-align: left;
color: var(--color-text-base);
text-transform: uppercase;
font-size: var(--font-size-h4);
padding: var(--widget-content-vertical-padding) 0;
background: var(--color-widget-background);
}
.expand-toggle-button.container-expanded {
position: sticky;
/* -1px to hide 1px gap on chrome */
bottom: -1px;
}
.expand-toggle-button-icon {
display: inline-block;
margin-left: 1rem;
position: relative;
top: -.2rem;
}
.expand-toggle-button-icon::before {
content: '' / "";
font-size: 0.8rem;
transform: rotate(90deg);
line-height: 1;
display: inline-block;
transition: transform 0.3s;
}
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
transform: rotate(-90deg);
}
.cards-grid.collapsible-container + .expand-toggle-button {
text-align: center;
margin-top: 0.5rem;
background-color: var(--color-background);
}
.widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0;
}
.carousel-container {
position: relative;
}
.carousel-container::before, .carousel-container::after {
content: '';
position: absolute;
width: 2rem;
top: 0;
bottom: 1rem;
z-index: 10;
opacity: 0;
pointer-events: none;
transition-duration: 0.2s;
}
.carousel-container::before {
background: linear-gradient(to right, var(--color-background), transparent);
}
.carousel-container::after {
right: 0;
background: linear-gradient(to left, var(--color-background), transparent);
}
.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after {
opacity: 1;
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
:root:not([data-scheme=light]) .flat-icon {
filter: invert(1);
}
.attachments > * {
border-radius: var(--border-radius);
padding: 0.1rem 0.5rem;
font-size: var(--font-size-h6);
background-color: var(--color-separator);
}
.progress-bar {
border: 1px solid var(--color-progress-border);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px;
height: 1.5rem;
/* naughty, but oh so beautiful */
margin-inline: -3px;
}
.progress-bar-combined {
height: 3rem;
}
.popover-active > .progress-bar {
transition: border-color .3s;
border-color: var(--color-text-subdue);
}
.progress-value {
--half-border-radius: calc(var(--border-radius) / 2);
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
background: var(--color-progress-value);
width: calc(var(--percent) * 1%);
min-width: 1px;
flex: 1;
}
.progress-value:first-child {
border-top-left-radius: var(--half-border-radius);
}
.progress-value:last-child {
border-bottom-left-radius: var(--half-border-radius);
}
.progress-value-notice {
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
}
.value-separator {
min-width: 2rem;
margin-inline: 0.8rem;
flex: 1;
height: calc(1em * 1.1);
border-bottom: 1px dotted var(--color-text-subdue);
}
.thumbnail {
filter: grayscale(0.2) contrast(0.9);
opacity: 0.8;
transition: filter 0.2s, opacity .2s;
}
.thumbnail-container {
flex-shrink: 0;
border: 1px solid var(--color-separator);
border-radius: var(--border-radius);
}
.thumbnail-container > * {
border-radius: var(--border-radius);
object-fit: cover;
}
.thumbnail-parent:hover .thumbnail {
opacity: 1;
filter: none;
}
.hide-scrollbars {
scrollbar-width: none;
}
/* Hide on Safari and Chrome */
.hide-scrollbars::-webkit-scrollbar {
display: none;
}
.ui-icon {
width: 2.3rem;
height: 2.3rem;
display: block;
flex-shrink: 0;
}
.size-h1 { font-size: var(--font-size-h1); }
.size-h2 { font-size: var(--font-size-h2); }
.size-h3 { font-size: var(--font-size-h3); }
.size-h4 { font-size: var(--font-size-h4); }
.size-base { font-size: var(--font-size-base); }
.size-h5 { font-size: var(--font-size-h5); }
.size-h6 { font-size: var(--font-size-h6); }
.color-highlight { color: var(--color-text-highlight); }
.color-paragraph { color: var(--color-text-paragraph); }
.color-base { color: var(--color-text-base); }
.color-subdue { color: var(--color-text-subdue); }
.color-negative { color: var(--color-negative); }
.color-positive { color: var(--color-positive); }
.color-primary { color: var(--color-primary); }
.color-primary-if-not-visited:not(:visited) {
color: var(--color-primary);
}
.drag-and-drop-container {
position: relative;
}
.drag-and-drop-decoy {
outline: 1px dashed var(--color-primary);
opacity: 0.25;
border-radius: var(--border-radius);
}
.drag-and-drop-draggable {
position: absolute;
cursor: grabbing !important;
}
.drag-and-drop-draggable:empty {
display: none;
}
.drag-and-drop-draggable * {
cursor: grabbing !important;
}
.auto-scaling-textarea-container {
position: relative;
}
.auto-scaling-textarea {
position: absolute;
inset: 0;
background: none;
border: none;
font: inherit;
resize: none;
color: inherit;
overflow: hidden;
}
.auto-scaling-textarea:focus {
outline: none;
}
.auto-scaling-textarea-mimic {
white-space: pre-wrap;
min-height: 1lh;
user-select: none;
word-wrap: break-word;
font: inherit;
visibility: hidden;
}
.cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-elevate { margin-top: -0.2em; }
.text-compact { word-spacing: -0.18em; }
.text-very-compact { word-spacing: -0.35em; }
.rtl { direction: rtl; }
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; }
.block { display: block; }
.inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; }
.relative { position: relative; }
.flex { display: flex; }
.flex-1 { flex: 1; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.justify-between { justify-content: space-between; }
.justify-stretch { justify-content: stretch; }
.justify-evenly { justify-content: space-evenly; }
.justify-center { justify-content: center; }
.justify-end { justify-content: end; }
.uppercase { text-transform: uppercase; }
.grow { flex-grow: 1; }
.flex-column { flex-direction: column; }
.items-center { align-items: center; }
.self-center { align-self: center; }
.items-start { align-items: start; }
.items-end { align-items: end; }
.gap-5 { gap: 0.5rem; }
.gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; }
.gap-12 { gap: 1.2rem; }
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; }
.gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; }
.margin-top-10 { margin-top: 1rem; }
.margin-top-15 { margin-top: 1.5rem; }
.margin-top-20 { margin-top: 2rem; }
.margin-top-25 { margin-top: 2.5rem; }
.margin-top-35 { margin-top: 3.5rem; }
.margin-top-40 { margin-top: 4rem; }
.margin-top-auto { margin-top: auto; }
.margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; }
.margin-block-8 { margin-block: 0.8rem; }
.margin-block-10 { margin-block: 1rem; }
.margin-block-15 { margin-block: 1.5rem; }
.margin-bottom-3 { margin-bottom: 0.3rem; }
.margin-bottom-5 { margin-bottom: 0.5rem; }
.margin-bottom-7 { margin-bottom: 0.7rem; }
.margin-bottom-10 { margin-bottom: 1rem; }
.margin-bottom-15 { margin-bottom: 1.5rem; }
.margin-bottom-auto { margin-bottom: auto; }
.margin-bottom-widget { margin-bottom: var(--widget-content-vertical-padding); }
.padding-widget { padding: var(--widget-content-padding); }
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
.pointer-events-none { pointer-events: none; }
.select-none { user-select: none; }
.padding-block-5 { padding-block: 0.5rem; }
.scale-half { transform: scale(0.5); }
.list { --list-half-gap: 0rem; }
.list-gap-2 { --list-half-gap: 0.1rem; }
.list-gap-4 { --list-half-gap: 0.2rem; }
.list-gap-8 { --list-half-gap: 0.4rem; }
.list-gap-10 { --list-half-gap: 0.5rem; }
.list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; }
.list-gap-24 { --list-half-gap: 1.2rem; }
.list-gap-34 { --list-half-gap: 1.7rem; }
@media (max-width: 1190px) {
.size-base-on-mobile { font-size: var(--font-size-base); }
}
@@ -0,0 +1,31 @@
.bookmarks-group {
--bookmarks-group-color: var(--color-primary);
}
.bookmarks-group-title {
color: var(--bookmarks-group-color);
}
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
content: '↗' / "";
margin-left: 0.5em;
display: inline-block;
position: relative;
top: 0.15em;
color: var(--bookmarks-group-color);
}
.bookmarks-icon-container {
margin-block: 0.1rem;
background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius);
padding: 0.5rem;
opacity: 0.7;
flex-shrink: 0;
}
.bookmarks-icon {
width: 20px;
height: 20px;
opacity: 0.8;
}
@@ -0,0 +1,71 @@
.old-calendar-day {
width: calc(100% / 7);
text-align: center;
padding: 0.6rem 0;
}
.old-calendar-day-today {
border-radius: var(--border-radius);
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
color: var(--color-text-highlight);
}
.calendar-dates {
text-align: center;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.calendar-date {
padding: 0.4rem 0;
color: var(--color-text-base);
position: relative;
border-radius: var(--border-radius);
background: none;
border: none;
font: inherit;
}
.calendar-current-date {
border-radius: var(--border-radius);
background-color: var(--color-popover-border);
color: var(--color-text-highlight);
}
.calendar-spillover-date {
color: var(--color-text-subdue);
}
.calendar-header-button {
position: relative;
cursor: pointer;
width: 2rem;
height: 2rem;
z-index: 1;
background: none;
border: none;
}
.calendar-header-button::before {
content: '';
position: absolute;
inset: -0.2rem;
border-radius: var(--border-radius);
background-color: var(--color-text-subdue);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.calendar-header-button:hover::before {
opacity: 0.4;
}
.calendar-undo-button {
display: inline-block;
vertical-align: text-top;
width: 2rem;
height: 2rem;
margin-left: 0.7rem;
}
@@ -0,0 +1,7 @@
.clock-time {
min-width: 8ch;
}
.clock-time span {
color: var(--color-text-highlight);
}
@@ -0,0 +1,120 @@
.dns-stats-totals {
transition: opacity .3s;
transition-delay: 50ms;
}
.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
opacity: 0.1;
transition-delay: 0s;
}
.dns-stats-graph {
--graph-height: 70px;
height: var(--graph-height);
position: relative;
margin-bottom: 2.5rem;
}
.dns-stats-graph-gridlines-container {
position: absolute;
inset: 0;
}
.dns-stats-graph-gridlines {
height: 100%;
width: 100%;
}
.dns-stats-graph-columns {
display: flex;
height: 100%;
}
.dns-stats-graph-column {
display: flex;
justify-content: flex-end;
align-items: center;
flex-direction: column;
width: calc(100% / 8);
position: relative;
}
.dns-stats-graph-column::before {
content: '';
position: absolute;
inset: 1px 0;
opacity: 0;
background: var(--color-text-base);
transition: opacity .2s;
}
.dns-stats-graph-column:hover::before {
opacity: 0.05;
}
.dns-stats-graph-bar {
width: 14px;
height: calc((var(--bar-height) / 100) * var(--graph-height));
border: 1px solid var(--color-progress-border);
border-radius: var(--border-radius) var(--border-radius) 0 0;
display: flex;
background: var(--color-widget-background);
padding: 2px 2px 0 2px;
flex-direction: column;
gap: 2px;
transition: border-color .2s;
min-height: 10px;
}
.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
border-color: var(--color-text-subdue);
border-bottom-color: var(--color-progress-border);
}
.dns-stats-graph-bar > * {
border-radius: 2px;
background: var(--color-vertical-progress-value);
min-height: 1px;
}
.dns-stats-graph-bar > .queries {
flex-grow: 1;
}
.dns-stats-graph-bar > *:last-child {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.dns-stats-graph-bar > .blocked {
background-color: var(--color-negative);
flex-basis: calc(var(--percent) - 1px);
}
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
opacity: 1;
transform: translateY(0);
}
.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
position: absolute;
font-size: var(--font-size-h6);
inset-inline: 0;
text-align: center;
height: 2.5rem;
line-height: 2.5rem;
top: 100%;
user-select: none;
opacity: 0;
transform: translateY(-0.5rem);
transition: opacity .2s, transform .2s;
}
.dns-stats-graph-column:hover .dns-stats-graph-time {
opacity: 1;
transform: translateY(0);
}
.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
opacity: 0;
}

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