widget.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. package widget
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "html/template"
  8. "log/slog"
  9. "math"
  10. "net/http"
  11. "sync/atomic"
  12. "time"
  13. "github.com/glanceapp/glance/internal/feed"
  14. "gopkg.in/yaml.v3"
  15. )
  16. var uniqueID atomic.Uint64
  17. func New(widgetType string) (Widget, error) {
  18. var widget Widget
  19. switch widgetType {
  20. case "calendar":
  21. widget = &Calendar{}
  22. case "clock":
  23. widget = &Clock{}
  24. case "weather":
  25. widget = &Weather{}
  26. case "bookmarks":
  27. widget = &Bookmarks{}
  28. case "iframe":
  29. widget = &IFrame{}
  30. case "html":
  31. widget = &HTML{}
  32. case "hacker-news":
  33. widget = &HackerNews{}
  34. case "releases":
  35. widget = &Releases{}
  36. case "videos":
  37. widget = &Videos{}
  38. case "markets", "stocks":
  39. widget = &Markets{}
  40. case "reddit":
  41. widget = &Reddit{}
  42. case "rss":
  43. widget = &RSS{}
  44. case "monitor":
  45. widget = &Monitor{}
  46. case "twitch-top-games":
  47. widget = &TwitchGames{}
  48. case "twitch-channels":
  49. widget = &TwitchChannels{}
  50. case "lobsters":
  51. widget = &Lobsters{}
  52. case "change-detection":
  53. widget = &ChangeDetection{}
  54. case "repository":
  55. widget = &Repository{}
  56. case "search":
  57. widget = &Search{}
  58. case "extension":
  59. widget = &Extension{}
  60. case "group":
  61. widget = &Group{}
  62. case "dns-stats":
  63. widget = &DNSStats{}
  64. case "split-column":
  65. widget = &SplitColumn{}
  66. case "custom-api":
  67. widget = &CustomApi{}
  68. case "docker":
  69. widget = &Docker{}
  70. default:
  71. return nil, fmt.Errorf("unknown widget type: %s", widgetType)
  72. }
  73. widget.SetID(uniqueID.Add(1))
  74. return widget, nil
  75. }
  76. type Widgets []Widget
  77. func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
  78. var nodes []yaml.Node
  79. if err := node.Decode(&nodes); err != nil {
  80. return err
  81. }
  82. for _, node := range nodes {
  83. meta := struct {
  84. Type string `yaml:"type"`
  85. }{}
  86. if err := node.Decode(&meta); err != nil {
  87. return err
  88. }
  89. widget, err := New(meta.Type)
  90. if err != nil {
  91. return err
  92. }
  93. if err = node.Decode(widget); err != nil {
  94. return err
  95. }
  96. *w = append(*w, widget)
  97. }
  98. return nil
  99. }
  100. type Widget interface {
  101. Initialize() error
  102. RequiresUpdate(*time.Time) bool
  103. SetProviders(*Providers)
  104. Update(context.Context)
  105. Render() template.HTML
  106. GetType() string
  107. GetID() uint64
  108. SetID(uint64)
  109. HandleRequest(w http.ResponseWriter, r *http.Request)
  110. SetHideHeader(bool)
  111. }
  112. type cacheType int
  113. const (
  114. cacheTypeInfinite cacheType = iota
  115. cacheTypeDuration
  116. cacheTypeOnTheHour
  117. )
  118. type widgetBase struct {
  119. ID uint64 `yaml:"-"`
  120. Providers *Providers `yaml:"-"`
  121. Type string `yaml:"type"`
  122. Title string `yaml:"title"`
  123. TitleURL string `yaml:"title-url"`
  124. CSSClass string `yaml:"css-class"`
  125. CustomCacheDuration DurationField `yaml:"cache"`
  126. ContentAvailable bool `yaml:"-"`
  127. Error error `yaml:"-"`
  128. Notice error `yaml:"-"`
  129. templateBuffer bytes.Buffer `yaml:"-"`
  130. cacheDuration time.Duration `yaml:"-"`
  131. cacheType cacheType `yaml:"-"`
  132. nextUpdate time.Time `yaml:"-"`
  133. updateRetriedTimes int `yaml:"-"`
  134. HideHeader bool `yaml:"-"`
  135. }
  136. type Providers struct {
  137. AssetResolver func(string) string
  138. }
  139. func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
  140. if w.cacheType == cacheTypeInfinite {
  141. return false
  142. }
  143. if w.nextUpdate.IsZero() {
  144. return true
  145. }
  146. return now.After(w.nextUpdate)
  147. }
  148. func (w *widgetBase) Update(ctx context.Context) {
  149. }
  150. func (w *widgetBase) GetID() uint64 {
  151. return w.ID
  152. }
  153. func (w *widgetBase) SetID(id uint64) {
  154. w.ID = id
  155. }
  156. func (w *widgetBase) SetHideHeader(value bool) {
  157. w.HideHeader = value
  158. }
  159. func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
  160. http.Error(w, "not implemented", http.StatusNotImplemented)
  161. }
  162. func (w *widgetBase) GetType() string {
  163. return w.Type
  164. }
  165. func (w *widgetBase) SetProviders(providers *Providers) {
  166. w.Providers = providers
  167. }
  168. func (w *widgetBase) render(data any, t *template.Template) template.HTML {
  169. w.templateBuffer.Reset()
  170. err := t.Execute(&w.templateBuffer, data)
  171. if err != nil {
  172. w.ContentAvailable = false
  173. w.Error = err
  174. slog.Error("failed to render template", "error", err)
  175. // need to immediately re-render with the error,
  176. // otherwise risk breaking the page since the widget
  177. // will likely be partially rendered with tags not closed.
  178. w.templateBuffer.Reset()
  179. err2 := t.Execute(&w.templateBuffer, data)
  180. if err2 != nil {
  181. slog.Error("failed to render error within widget", "error", err2, "initial_error", err)
  182. w.templateBuffer.Reset()
  183. // TODO: add some kind of a generic widget error template when the widget
  184. // failed to render, and we also failed to re-render the widget with the error
  185. }
  186. }
  187. return template.HTML(w.templateBuffer.String())
  188. }
  189. func (w *widgetBase) withTitle(title string) *widgetBase {
  190. if w.Title == "" {
  191. w.Title = title
  192. }
  193. return w
  194. }
  195. func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
  196. if w.TitleURL == "" {
  197. w.TitleURL = titleURL
  198. }
  199. return w
  200. }
  201. func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
  202. w.cacheType = cacheTypeDuration
  203. if duration == -1 || w.CustomCacheDuration == 0 {
  204. w.cacheDuration = duration
  205. } else {
  206. w.cacheDuration = time.Duration(w.CustomCacheDuration)
  207. }
  208. return w
  209. }
  210. func (w *widgetBase) withCacheOnTheHour() *widgetBase {
  211. w.cacheType = cacheTypeOnTheHour
  212. return w
  213. }
  214. func (w *widgetBase) withNotice(err error) *widgetBase {
  215. w.Notice = err
  216. return w
  217. }
  218. func (w *widgetBase) withError(err error) *widgetBase {
  219. if err == nil && !w.ContentAvailable {
  220. w.ContentAvailable = true
  221. }
  222. w.Error = err
  223. return w
  224. }
  225. func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool {
  226. // TODO: needs covering more edge cases.
  227. // if there's partial content and we update early there's a chance
  228. // the early update returns even less content than the initial update.
  229. // need some kind of mechanism that tells us whether we should update early
  230. // or not depending on the number of things that failed during the initial
  231. // and subsequent update and how they failed - ie whether it was server
  232. // error (like gateway timeout, do retry early) or client error (like
  233. // hitting a rate limit, don't retry early). will require reworking a
  234. // good amount of code in the feed package and probably having a custom
  235. // error type that holds more information because screw wrapping errors.
  236. // alternatively have a resource cache and only refetch the failed resources,
  237. // then rebuild the widget.
  238. if err != nil {
  239. w.scheduleEarlyUpdate()
  240. if !errors.Is(err, feed.ErrPartialContent) {
  241. w.withError(err)
  242. w.withNotice(nil)
  243. return false
  244. }
  245. w.withError(nil)
  246. w.withNotice(err)
  247. return true
  248. }
  249. w.withNotice(nil)
  250. w.withError(nil)
  251. w.scheduleNextUpdate()
  252. return true
  253. }
  254. func (w *widgetBase) getNextUpdateTime() time.Time {
  255. now := time.Now()
  256. if w.cacheType == cacheTypeDuration {
  257. return now.Add(w.cacheDuration)
  258. }
  259. if w.cacheType == cacheTypeOnTheHour {
  260. return now.Add(time.Duration(
  261. ((60-now.Minute())*60)-now.Second(),
  262. ) * time.Second)
  263. }
  264. return time.Time{}
  265. }
  266. func (w *widgetBase) scheduleNextUpdate() *widgetBase {
  267. w.nextUpdate = w.getNextUpdateTime()
  268. w.updateRetriedTimes = 0
  269. return w
  270. }
  271. func (w *widgetBase) scheduleEarlyUpdate() *widgetBase {
  272. w.updateRetriedTimes++
  273. if w.updateRetriedTimes > 5 {
  274. w.updateRetriedTimes = 5
  275. }
  276. nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute)
  277. nextUsualUpdate := w.getNextUpdateTime()
  278. if nextEarlyUpdate.After(nextUsualUpdate) {
  279. w.nextUpdate = nextUsualUpdate
  280. } else {
  281. w.nextUpdate = nextEarlyUpdate
  282. }
  283. return w
  284. }