+285
-12
Diff
round #2
+99
-3
store/store.go
+99
-3
store/store.go
···
10
10
"encoding/json"
11
11
"fmt"
12
12
"io"
13
+
"sort"
13
14
"time"
14
15
15
16
"entgo.io/ent/dialect"
···
395
396
return out, rows.Err()
396
397
}
397
398
398
-
// RecentRepos returns repos created since `since`, optionally filtered by
399
-
// primary language. Pass limit <= 0 for no limit. Uses raw SQL for the
400
-
// scalar star-count subquery and handle JOIN — both awkward in ent.
399
+
// PrimaryLanguages returns the sorted set of "primary" languages observed
400
+
// across all enriched repos — i.e., the highest-bytes language per repo,
401
+
// deduplicated. Used to populate the language filter dropdown.
402
+
func (s *Store) PrimaryLanguages(ctx context.Context) ([]string, error) {
403
+
rows, err := s.db.QueryContext(ctx, `SELECT languages FROM repos WHERE languages IS NOT NULL AND languages != '' AND languages != '{}'`)
404
+
if err != nil {
405
+
return nil, err
406
+
}
407
+
defer rows.Close()
408
+
seen := make(map[string]struct{})
409
+
for rows.Next() {
410
+
var raw string
411
+
if err := rows.Scan(&raw); err != nil {
412
+
return nil, err
413
+
}
414
+
var langs map[string]int64
415
+
if err := json.Unmarshal([]byte(raw), &langs); err != nil {
416
+
continue
417
+
}
418
+
var best string
419
+
var bestN int64
420
+
for k, v := range langs {
421
+
if v > bestN {
422
+
bestN, best = v, k
423
+
}
424
+
}
425
+
if best != "" {
426
+
seen[best] = struct{}{}
427
+
}
428
+
}
429
+
if err := rows.Err(); err != nil {
430
+
return nil, err
431
+
}
432
+
out := make([]string, 0, len(seen))
433
+
for k := range seen {
434
+
out = append(out, k)
435
+
}
436
+
sort.Strings(out)
437
+
return out, nil
438
+
}
439
+
440
+
// ReposFilter narrows the result of RecentRepos. Zero values mean "no
441
+
// filter" so callers can mix and match.
442
+
type ReposFilter struct {
443
+
Language string // primary language match (post-filter in Go over JSON map)
444
+
Since time.Time // repos created at or after this time (zero = no time floor)
445
+
ForksOnly bool // only rows with non-empty source
446
+
NoForks bool // exclude rows with non-empty source
447
+
}
448
+
449
+
// RecentRepos returns repos matching the filter, sorted newest first. Pass
450
+
// limit <= 0 for no limit. Uses raw SQL because the scalar star-count
451
+
// subquery and handle LEFT JOIN are awkward to express via ent's query API.
452
+
func (s *Store) RecentRepos(ctx context.Context, f ReposFilter, limit int) ([]Repo, error) {
453
+
candidateLimit := -1
454
+
if limit > 0 {
455
+
candidateLimit = limit
456
+
if f.Language != "" {
457
+
candidateLimit = limit * 5
458
+
}
459
+
}
460
+
forksOnly := 0
461
+
if f.ForksOnly {
462
+
forksOnly = 1
463
+
}
464
+
noForks := 0
465
+
if f.NoForks {
466
+
noForks = 1
467
+
}
468
+
rows, err := s.db.QueryContext(ctx, `
469
+
SELECT r.at_uri, r.did, r.rkey, r.name, r.knot, r.description, r.topics,
470
+
r.website, r.source, r.spindle, r.repo_did, r.created_at, r.seen_at,
471
+
472
+
473
+
474
+
LEFT JOIN handles h ON h.did = r.did
475
+
WHERE r.created_at >= ?
476
+
AND (? = '' OR r.languages IS NOT NULL)
477
+
AND (? = 0 OR (r.source IS NOT NULL AND r.source != ''))
478
+
AND (? = 0 OR r.source IS NULL OR r.source = '')
479
+
ORDER BY r.created_at DESC
480
+
LIMIT ?
481
+
`, f.Since.UnixMilli(), f.Language, forksOnly, noForks, candidateLimit)
482
+
if err != nil {
483
+
return nil, err
484
+
}
485
+
486
+
487
+
488
+
489
+
490
+
if err != nil {
491
+
return nil, err
492
+
}
493
+
if f.Language != "" && r.Primary() != f.Language {
494
+
continue
495
+
}
496
+
out = append(out, r)
+71
-7
web/server.go
+71
-7
web/server.go
···
27
27
28
28
29
29
30
+
Language string
31
+
SinceRaw string // empty = no time filter
32
+
Threshold time.Time // zero = no time filter
33
+
Forks string // "" or "only"
34
+
}
35
+
36
+
func parseQuery(r *http.Request) query {
37
+
q := query{
38
+
Language: r.URL.Query().Get("language"),
39
+
SinceRaw: r.URL.Query().Get("since"),
40
+
Forks: r.URL.Query().Get("forks"),
41
+
}
42
+
if q.SinceRaw != "" {
43
+
if d, err := time.ParseDuration(q.SinceRaw); err == nil {
30
44
31
45
32
46
47
+
return q
48
+
}
33
49
50
+
func (q query) filter() store.ReposFilter {
51
+
return store.ReposFilter{
52
+
Language: q.Language,
53
+
Since: q.Threshold,
54
+
ForksOnly: q.Forks == "only",
55
+
NoForks: q.Forks == "no",
56
+
}
57
+
}
34
58
59
+
func reposJSON(s *store.Store) http.HandlerFunc {
60
+
return func(w http.ResponseWriter, r *http.Request) {
61
+
q := parseQuery(r)
62
+
repos, err := s.RecentRepos(r.Context(), q.filter(), 0)
63
+
if err != nil {
64
+
http.Error(w, err.Error(), http.StatusInternalServerError)
65
+
return
35
66
36
67
37
68
38
69
39
70
40
71
72
+
func reposHTML(s *store.Store) http.HandlerFunc {
73
+
return func(w http.ResponseWriter, r *http.Request) {
74
+
q := parseQuery(r)
75
+
repos, err := s.RecentRepos(r.Context(), q.filter(), 0)
76
+
if err != nil {
77
+
http.Error(w, err.Error(), http.StatusInternalServerError)
78
+
return
79
+
}
80
+
langs, err := s.PrimaryLanguages(r.Context())
81
+
if err != nil {
82
+
http.Error(w, err.Error(), http.StatusInternalServerError)
83
+
return
84
+
}
85
+
render(w, "repos", struct {
86
+
Active string
87
+
Stats Stats
88
+
Query query
89
+
Repos []store.Repo
90
+
Languages []string
91
+
}{"repos", collectStats(), q, repos, langs})
92
+
}
93
+
}
94
+
95
+
96
+
97
+
98
+
41
99
42
100
101
+
}
102
+
render(w, "knots", struct {
103
+
Active string
104
+
Stats Stats
105
+
Knots []store.KnotEntry
106
+
}{"knots", collectStats(), knots})
107
+
}
108
+
}
43
109
44
110
45
111
···
63
129
64
130
65
131
66
-
http.Error(w, err.Error(), http.StatusInternalServerError)
67
-
return
68
132
}
69
-
render(w, "repos", struct {
70
-
Active string
71
-
Query query
72
-
Repos []store.Repo
73
-
}{"repos", q, repos})
133
+
render(w, "handles", struct {
134
+
Active string
135
+
Stats Stats
136
+
Handles []store.HandleEntry
137
+
}{"handles", collectStats(), handles})
74
138
}
75
139
}
76
140
+33
-1
web/templates/repos.html
+33
-1
web/templates/repos.html
···
1
1
{{define "repos"}}{{template "head" .}}
2
2
<form method="get">
3
-
<label>language<input name="language" value="{{.Query.Language}}" placeholder="any"></label>
3
+
<label>language
4
+
<select name="language">
5
+
<option value=""{{if eq .Query.Language ""}} selected{{end}}>any</option>
6
+
{{range .Languages}}
7
+
<option value="{{.}}"{{if eq $.Query.Language .}} selected{{end}}>{{.}}</option>
8
+
{{end}}
9
+
</select>
10
+
</label>
4
11
<label>since<input name="since" value="{{.Query.SinceRaw}}" placeholder="e.g. 24h"></label>
12
+
<label>forks
13
+
<select name="forks">
14
+
<option value=""{{if eq .Query.Forks ""}} selected{{end}}>any</option>
15
+
<option value="only"{{if eq .Query.Forks "only"}} selected{{end}}>only forks</option>
16
+
<option value="no"{{if eq .Query.Forks "no"}} selected{{end}}>no forks</option>
17
+
</select>
18
+
</label>
5
19
<button>filter</button>
6
20
</form>
21
+
<p class="summary">{{len .Repos}} repos · <a href="/repos.json?language={{.Query.Language}}&since={{.Query.SinceRaw}}&forks={{.Query.Forks}}">json</a></p>
22
+
{{if .Repos}}
23
+
<table>
24
+
<thead><tr><th>created</th><th>repo</th><th>knot</th><th>language</th><th>★</th><th>description</th></tr></thead>
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
{{- else -}}
33
+
{{.DID}}/{{.Name}}
34
+
{{- end -}}
35
+
{{if .Source}} <span class="fork" title="forked from {{.Source}}">↗</span>{{end}}
36
+
</td>
37
+
<td class="knot">{{.Knot}}</td>
38
+
<td class="lang">{{.Primary}}</td>
+31
web/templates/layout.html
+31
web/templates/layout.html
···
22
22
td.handle { font-weight: 600; }
23
23
td.handle a, td.knot a, .link a { color: inherit; text-decoration: none; border-bottom: 1px dotted currentColor; }
24
24
td.handle a:hover, td.knot a:hover, .link a:hover { border-bottom-style: solid; }
25
+
.fork { color: #888; cursor: help; margin-left: .25em; }
25
26
td.knot, td.did { color: #888; }
26
27
td.lang { color: #6a6; }
27
28
td.num { text-align: right; font-variant-numeric: tabular-nums; color: #888; }
29
+
td.num.has { color: inherit; }
30
+
td.desc { color: #aaa; max-width: 480px; word-break: break-word; }
31
+
.empty { padding: 3em; text-align: center; color: #888; border: 1px dashed currentColor; opacity: .4; }
32
+
footer.stats { margin-top: 2em; padding-top: .5em; border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); color: #888; font-size: 12px; display: flex; gap: 1em; flex-wrap: wrap; }
33
+
footer.stats span { white-space: nowrap; }
34
+
</style>
35
+
</head>
36
+
<body>
37
+
38
+
39
+
40
+
41
+
42
+
43
+
{{end}}
44
+
45
+
{{define "foot"}}
46
+
{{with .Stats}}
47
+
<footer class="stats">
48
+
<span>uptime {{.Uptime}}</span>
49
+
<span>goroutines {{.Goroutines}}</span>
50
+
<span>heap {{printf "%.1f" .HeapAllocMB}} MB</span>
51
+
<span>rss {{printf "%.1f" .RSSMB}} MB</span>
52
+
<span>cpu {{printf "%.1f" .CPUSec}}s</span>
53
+
<span>gc {{.GCRuns}}</span>
54
+
</footer>
55
+
{{end}}
56
+
</body>
57
+
</html>
58
+
{{end}}
+49
web/stats.go
+49
web/stats.go
···
1
+
package web
2
+
3
+
import (
4
+
"runtime"
5
+
"syscall"
6
+
"time"
7
+
)
8
+
9
+
// Stats is a small snapshot of process-level resource usage rendered into
10
+
// every page footer. Pure stdlib — runtime for Go heap, getrusage for OS-level
11
+
// RSS and CPU time.
12
+
type Stats struct {
13
+
Goroutines int
14
+
HeapAllocMB float64 // Go heap currently in use
15
+
SysMB float64 // total bytes obtained from the OS by the runtime
16
+
GCRuns uint32
17
+
RSSMB float64 // resident set size (peak since start)
18
+
CPUSec float64 // user + system CPU seconds since start
19
+
Uptime time.Duration // process uptime, rounded to seconds
20
+
}
21
+
22
+
var startTime = time.Now()
23
+
24
+
func collectStats() Stats {
25
+
var ms runtime.MemStats
26
+
runtime.ReadMemStats(&ms)
27
+
28
+
var ru syscall.Rusage
29
+
_ = syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
30
+
31
+
// ru.Maxrss is bytes on Darwin/BSD, kilobytes on Linux.
32
+
rssBytes := int64(ru.Maxrss)
33
+
if runtime.GOOS == "linux" {
34
+
rssBytes *= 1024
35
+
}
36
+
37
+
cpuSec := float64(ru.Utime.Sec) + float64(ru.Utime.Usec)/1e6 +
38
+
float64(ru.Stime.Sec) + float64(ru.Stime.Usec)/1e6
39
+
40
+
return Stats{
41
+
Goroutines: runtime.NumGoroutine(),
42
+
HeapAllocMB: float64(ms.HeapAlloc) / 1024 / 1024,
43
+
SysMB: float64(ms.Sys) / 1024 / 1024,
44
+
GCRuns: ms.NumGC,
45
+
RSSMB: float64(rssBytes) / 1024 / 1024,
46
+
CPUSec: cpuSec,
47
+
Uptime: time.Since(startTime).Round(time.Second),
48
+
}
49
+
}
History
3 rounds
0 comments
4 commits
expand
collapse
feat: filter by language
feat(web): additional filters
filter by forks/no forks/any
make language a dropdown (bad ux, but less typing)
feat(web): live stats
fix(ci): install make
merge conflicts detected
expand
collapse
expand
collapse
- store/store.go:10
- web/server.go:66
- web/templates/repos.html:1