vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Build a landing page and simplify the forge chrome
074ae1605e177e99533af02d8c69ccd1a6a947bb · Keenan Nemetz · 14d ago · parent 62576fa8
Build a landing page and simplify the forge chrome The instance root is now a landing page rather than the full activity feed: forge identity, project cards with top language, last commit, issue/change counts and latest release, a short activity rail, and a first-run empty state. The full feed moves to /activity with type filters and pagination. Feeds now cover issues, changes, comments, and releases only; commits and CI runs already have their own pages. Configuration gains instance_name and instance_tagline. Navigation: the crowded global bar becomes Runners, Activity, search, and an account menu holding Admin, Status, and Log out. Repository pages show the name and description above the tabs at the home-page header scale, while tab-page titles stay small. Top-level pages keep the larger header. Drop the workflow run chip, assigned-issues strip, repository size stat, and footer Status link.
server/web/home.go +348
| @@ -0,0 +1,348 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // The landing page and the instance-wide activity feed. | ||
| 4 | // | ||
| 5 | // The landing page answers three questions in one screen: what this forge is, | ||
| 6 | // what is in it, and what happened lately. Repositories are the main column; the | ||
| 7 | // merged feed is a rail beside them. Both come from one read per repository, so | ||
| 8 | // listing a project does not cost a second walk over its history. | ||
| 9 | |||
| 10 | import ( | ||
| 11 | "net/http" | ||
| 12 | "sort" | ||
| 13 | "strconv" | ||
| 14 | "strings" | ||
| 15 | "time" | ||
| 16 | |||
| 17 | "github.com/rodent-software/vole/core/git" | ||
| 18 | "github.com/rodent-software/vole/forge/access" | ||
| 19 | "github.com/rodent-software/vole/forge/index" | ||
| 20 | ) | ||
| 21 | |||
| 22 | const ( | ||
| 23 | // homeEventLimit is what fits in the rail without turning the landing page | ||
| 24 | // back into a feed. The full feed has its own page at /activity. | ||
| 25 | homeEventLimit = 10 | ||
| 26 | // instanceEventLimit bounds /activity after the per-repository feeds are | ||
| 27 | // merged; instanceEventPage is what one page of it shows. | ||
| 28 | instanceEventLimit = 200 | ||
| 29 | instanceEventPage = 50 | ||
| 30 | ) | ||
| 31 | |||
| 32 | // instanceInfo is the identity the landing page shows. It is derived from the | ||
| 33 | // configuration rather than hard-coded, so an operator can name their forge | ||
| 34 | // without changing the wordmark. | ||
| 35 | type instanceInfo struct { | ||
| 36 | Name string | ||
| 37 | Tagline string | ||
| 38 | } | ||
| 39 | |||
| 40 | // homeVitals are the signs of life on the landing page. Every number is derived | ||
| 41 | // from what the viewer can read: a private repository's activity is not counted. | ||
| 42 | type homeVitals struct { | ||
| 43 | Repos int | ||
| 44 | Runners int | ||
| 45 | ShowRunners bool | ||
| 46 | LastActivity time.Time | ||
| 47 | } | ||
| 48 | |||
| 49 | // repoCard is one repository in the project grid. It is a summary of the same | ||
| 50 | // activity feed the rail uses, not a second source of truth. | ||
| 51 | type repoCard struct { | ||
| 52 | Name string | ||
| 53 | Description string | ||
| 54 | Branch string | ||
| 55 | Private bool | ||
| 56 | Commit *git.Commit | ||
| 57 | LastTag *git.Tag | ||
| 58 | OpenIssues int | ||
| 59 | OpenChanges int | ||
| 60 | Language string | ||
| 61 | LangClass string | ||
| 62 | Updated time.Time | ||
| 63 | } | ||
| 64 | |||
| 65 | // pagerView is the state of one page in the instance feed. Prev and Next are 0 | ||
| 66 | // when there is no page in that direction. | ||
| 67 | type pagerView struct { | ||
| 68 | Number int | ||
| 69 | Pages int | ||
| 70 | Prev int | ||
| 71 | Next int | ||
| 72 | } | ||
| 73 | |||
| 74 | type homeData struct { | ||
| 75 | Page | ||
| 76 | Instance instanceInfo | ||
| 77 | Vitals homeVitals | ||
| 78 | Cards []repoCard | ||
| 79 | Events []activityEvent | ||
| 80 | OpenRegistration bool | ||
| 81 | } | ||
| 82 | |||
| 83 | type instanceActivityData struct { | ||
| 84 | Page | ||
| 85 | Kind string | ||
| 86 | Events []activityEvent | ||
| 87 | Pager pagerView | ||
| 88 | } | ||
| 89 | |||
| 90 | // IndexPage serves GET /{$}: the landing page, or repository search results when | ||
| 91 | // q is set. Search results reuse the project cards so a match looks like a | ||
| 92 | // project rather than a table row. | ||
| 93 | func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | ||
| 94 | query := strings.TrimSpace(r.URL.Query().Get("q")) | ||
| 95 | entries, err := w.visibleRepos(r, query) | ||
| 96 | if err != nil { | ||
| 97 | w.serverError(rw, r, "index unavailable: "+err.Error()) | ||
| 98 | return | ||
| 99 | } | ||
| 100 | |||
| 101 | page := w.page(r, "Repositories") | ||
| 102 | page.Query = query | ||
| 103 | data := homeData{ | ||
| 104 | Page: page, | ||
| 105 | Instance: w.instanceInfo(r), | ||
| 106 | OpenRegistration: w.Cfg != nil && w.Cfg.OpenRegistration, | ||
| 107 | } | ||
| 108 | |||
| 109 | var events []activityEvent | ||
| 110 | data.Cards, events = w.repoCards(entries) | ||
| 111 | if query != "" { | ||
| 112 | sort.SliceStable(data.Cards, func(i, j int) bool { return data.Cards[i].Name < data.Cards[j].Name }) | ||
| 113 | } else { | ||
| 114 | sortCards(data.Cards) | ||
| 115 | if len(events) > homeEventLimit { | ||
| 116 | events = events[:homeEventLimit] | ||
| 117 | } | ||
| 118 | data.Events = events | ||
| 119 | data.Vitals = w.homeVitals(r, entries, events) | ||
| 120 | } | ||
| 121 | w.render(rw, "index", data, 0) | ||
| 122 | } | ||
| 123 | |||
| 124 | // InstanceActivity serves GET /activity: the whole feed, with the type filter | ||
| 125 | // that would make the landing page rail too heavy. | ||
| 126 | func (w *Web) InstanceActivity(rw http.ResponseWriter, r *http.Request) { | ||
| 127 | kind := normalizeActivityKind(r.URL.Query().Get("kind")) | ||
| 128 | entries, err := w.visibleRepos(r, "") | ||
| 129 | if err != nil { | ||
| 130 | w.serverError(rw, r, "index unavailable: "+err.Error()) | ||
| 131 | return | ||
| 132 | } | ||
| 133 | number, _ := strconv.Atoi(r.URL.Query().Get("page")) | ||
| 134 | events, pager := paginate(w.instanceEvents(entries, kind), number, instanceEventPage) | ||
| 135 | w.render(rw, "instance_activity", instanceActivityData{ | ||
| 136 | Page: w.page(r, "Activity"), Kind: kind, Events: events, Pager: pager, | ||
| 137 | }, 0) | ||
| 138 | } | ||
| 139 | |||
| 140 | // visibleRepos returns the repositories the viewer may read, optionally narrowed | ||
| 141 | // by a name/description search. A repository the viewer cannot read is absent, | ||
| 142 | // the same answer as a repository that does not exist. | ||
| 143 | func (w *Web) visibleRepos(r *http.Request, query string) ([]index.Entry, error) { | ||
| 144 | entries, err := w.Index.List() | ||
| 145 | if err != nil { | ||
| 146 | return nil, err | ||
| 147 | } | ||
| 148 | needle := strings.ToLower(strings.TrimSpace(query)) | ||
| 149 | var visible []index.Entry | ||
| 150 | for _, e := range entries { | ||
| 151 | m, err := w.Repos.Open(e.Name) | ||
| 152 | if err != nil { | ||
| 153 | continue | ||
| 154 | } | ||
| 155 | if !w.canRead(r, m) { | ||
| 156 | continue | ||
| 157 | } | ||
| 158 | if needle != "" && | ||
| 159 | !strings.Contains(strings.ToLower(e.Name), needle) && | ||
| 160 | !strings.Contains(strings.ToLower(e.Description), needle) { | ||
| 161 | continue | ||
| 162 | } | ||
| 163 | visible = append(visible, e) | ||
| 164 | } | ||
| 165 | return visible, nil | ||
| 166 | } | ||
| 167 | |||
| 168 | // repoCards reads each repository once and returns its card plus its events with | ||
| 169 | // the repository named on them. It replaces the separate card and feed passes so | ||
| 170 | // the landing page does not walk each repository twice. | ||
| 171 | func (w *Web) repoCards(entries []index.Entry) ([]repoCard, []activityEvent) { | ||
| 172 | cards := make([]repoCard, 0, len(entries)) | ||
| 173 | var events []activityEvent | ||
| 174 | for _, e := range entries { | ||
| 175 | m, err := w.Repos.Open(e.Name) | ||
| 176 | if err != nil { | ||
| 177 | continue | ||
| 178 | } | ||
| 179 | feed := w.repoActivity(m, "", "", "") | ||
| 180 | card := repoCard{ | ||
| 181 | Name: e.Name, Description: e.Description, Branch: m.DefaultBranch, | ||
| 182 | Private: true, Commit: feed.LastCommit, LastTag: feed.LastTag, | ||
| 183 | OpenIssues: feed.OpenIssues, OpenChanges: feed.OpenChanges, | ||
| 184 | } | ||
| 185 | if feed.LastCommit != nil { | ||
| 186 | if tree, err := m.Git().LsTreeR(feed.LastCommit.SHA); err == nil { | ||
| 187 | card.Language, card.LangClass = topLanguage(tree) | ||
| 188 | } | ||
| 189 | } | ||
| 190 | // Visibility is the same ACL rule the repository header uses, so the | ||
| 191 | // lock/globe on a card cannot disagree with whether an anonymous clone | ||
| 192 | // works. | ||
| 193 | if acl, err := m.ACL(); err == nil { | ||
| 194 | card.Private = !acl.Can("", access.Read) | ||
| 195 | } | ||
| 196 | if len(feed.Events) > 0 { | ||
| 197 | card.Updated = feed.Events[0].Time | ||
| 198 | } else if feed.LastCommit != nil { | ||
| 199 | card.Updated = time.Unix(feed.LastCommit.When, 0).UTC() | ||
| 200 | } | ||
| 201 | cards = append(cards, card) | ||
| 202 | for _, ev := range feed.Events { | ||
| 203 | ev.Repo = m.Name | ||
| 204 | events = append(events, ev) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | sort.SliceStable(events, func(i, j int) bool { return events[i].Time.After(events[j].Time) }) | ||
| 208 | return cards, events | ||
| 209 | } | ||
| 210 | |||
| 211 | // instanceEvents merges the visible repositories' feeds for one kind. It is | ||
| 212 | // bounded after the merge: a per-repository cap would hide a quiet repository's | ||
| 213 | // only event on an instance with a busy neighbour. | ||
| 214 | func (w *Web) instanceEvents(entries []index.Entry, kind string) []activityEvent { | ||
| 215 | var events []activityEvent | ||
| 216 | for _, e := range entries { | ||
| 217 | m, err := w.Repos.Open(e.Name) | ||
| 218 | if err != nil { | ||
| 219 | continue | ||
| 220 | } | ||
| 221 | for _, ev := range w.repoActivity(m, kind, "", "").Events { | ||
| 222 | ev.Repo = m.Name | ||
| 223 | events = append(events, ev) | ||
| 224 | } | ||
| 225 | } | ||
| 226 | sort.SliceStable(events, func(i, j int) bool { return events[i].Time.After(events[j].Time) }) | ||
| 227 | if len(events) > instanceEventLimit { | ||
| 228 | events = events[:instanceEventLimit] | ||
| 229 | } | ||
| 230 | return events | ||
| 231 | } | ||
| 232 | |||
| 233 | // paginate slices one page out of the merged feed. A page number outside the | ||
| 234 | // range is clamped to the nearest page, so an old link or a hand-edited URL | ||
| 235 | // still renders something rather than an empty page. | ||
| 236 | func paginate(events []activityEvent, number, perPage int) ([]activityEvent, pagerView) { | ||
| 237 | if perPage < 1 || len(events) == 0 { | ||
| 238 | return nil, pagerView{Number: 1} | ||
| 239 | } | ||
| 240 | pages := (len(events) + perPage - 1) / perPage | ||
| 241 | if number < 1 { | ||
| 242 | number = 1 | ||
| 243 | } | ||
| 244 | if number > pages { | ||
| 245 | number = pages | ||
| 246 | } | ||
| 247 | start := (number - 1) * perPage | ||
| 248 | end := min(start+perPage, len(events)) | ||
| 249 | p := pagerView{Number: number, Pages: pages} | ||
| 250 | if number > 1 { | ||
| 251 | p.Prev = number - 1 | ||
| 252 | } | ||
| 253 | if number < pages { | ||
| 254 | p.Next = number + 1 | ||
| 255 | } | ||
| 256 | return events[start:end], p | ||
| 257 | } | ||
| 258 | |||
| 259 | // topLanguage guesses what a repository is mostly made of from the file names in | ||
| 260 | // its head tree. Counting files rather than bytes is deliberate: one generated | ||
| 261 | // binary or lock file should not decide the label, and the card only needs a | ||
| 262 | // hint. Unknown types are ignored unless the repository contains nothing else. | ||
| 263 | func topLanguage(tree []git.Entry) (name, class string) { | ||
| 264 | counts := map[string]int{} | ||
| 265 | sample := map[string]string{} | ||
| 266 | for _, e := range tree { | ||
| 267 | if e.Type != "blob" { | ||
| 268 | continue | ||
| 269 | } | ||
| 270 | lang := languageFor(e.Name) | ||
| 271 | counts[lang]++ | ||
| 272 | if _, ok := sample[lang]; !ok { | ||
| 273 | sample[lang] = e.Name | ||
| 274 | } | ||
| 275 | } | ||
| 276 | best := "" | ||
| 277 | for lang, n := range counts { | ||
| 278 | if lang == "Other" { | ||
| 279 | continue | ||
| 280 | } | ||
| 281 | if best == "" || n > counts[best] || (n == counts[best] && lang < best) { | ||
| 282 | best = lang | ||
| 283 | } | ||
| 284 | } | ||
| 285 | if best == "" { | ||
| 286 | return "", "" | ||
| 287 | } | ||
| 288 | return best, fileClass(sample[best]) | ||
| 289 | } | ||
| 290 | |||
| 291 | // sortCards puts the repository that changed most recently first. A repository | ||
| 292 | // with no history sorts last, by name, so a fresh instance still has a stable | ||
| 293 | // order. | ||
| 294 | func sortCards(cards []repoCard) { | ||
| 295 | sort.SliceStable(cards, func(i, j int) bool { | ||
| 296 | a, b := cards[i].Updated, cards[j].Updated | ||
| 297 | if !a.Equal(b) { | ||
| 298 | return a.After(b) | ||
| 299 | } | ||
| 300 | return cards[i].Name < cards[j].Name | ||
| 301 | }) | ||
| 302 | } | ||
| 303 | |||
| 304 | // homeVitals counts the connected runners the viewer is allowed to see. A | ||
| 305 | // session building a private repository is not counted, matching the runners | ||
| 306 | // page: naming it would confirm that the repository exists. | ||
| 307 | func (w *Web) homeVitals(r *http.Request, entries []index.Entry, events []activityEvent) homeVitals { | ||
| 308 | v := homeVitals{Repos: len(entries), ShowRunners: w.Runs != nil} | ||
| 309 | if len(events) > 0 { | ||
| 310 | v.LastActivity = events[0].Time | ||
| 311 | } | ||
| 312 | if w.Runs == nil { | ||
| 313 | return v | ||
| 314 | } | ||
| 315 | sessions, err := w.Runs.Sessions(time.Now().UTC().Add(-presenceWindow)) | ||
| 316 | if err != nil { | ||
| 317 | return v | ||
| 318 | } | ||
| 319 | for _, s := range sessions { | ||
| 320 | if s.Repo == "" { | ||
| 321 | v.Runners++ | ||
| 322 | continue | ||
| 323 | } | ||
| 324 | m, err := w.Repos.Open(s.Repo) | ||
| 325 | if err == nil && w.canRead(r, m) { | ||
| 326 | v.Runners++ | ||
| 327 | } | ||
| 328 | } | ||
| 329 | return v | ||
| 330 | } | ||
| 331 | |||
| 332 | // instanceInfo names the forge. The address is already in the browser bar, so | ||
| 333 | // the page does not repeat it. | ||
| 334 | func (w *Web) instanceInfo(_ *http.Request) instanceInfo { | ||
| 335 | info := instanceInfo{ | ||
| 336 | Name: "Vole", | ||
| 337 | Tagline: "Code, issues, changes, and CI, hosted here.", | ||
| 338 | } | ||
| 339 | if w.Cfg != nil { | ||
| 340 | if v := strings.TrimSpace(w.Cfg.InstanceName); v != "" { | ||
| 341 | info.Name = v | ||
| 342 | } | ||
| 343 | if v := strings.TrimSpace(w.Cfg.InstanceTagline); v != "" { | ||
| 344 | info.Tagline = v | ||
| 345 | } | ||
| 346 | } | ||
| 347 | return info | ||
| 348 | } | ||