From a3bce1c10a4ee29a820bac14ea9604d2bdfb3f1a Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Mon, 23 Jun 2025 15:01:24 +0100 Subject: [PATCH] appview/pages: add spindle dashboard UI Signed-off-by: oppiliappan --- appview/db/repos.go | 187 ++++++++++++++++++ appview/pages/pages.go | 15 +- .../pages/templates/spindles/dashboard.html | 119 +++++++++++ .../spindles/fragments/addMemberModal.html | 57 ++++++ .../spindles/fragments/spindleListing.html | 25 ++- appview/pages/templates/spindles/index.html | 7 +- appview/state/router.go | 13 +- 7 files changed, 409 insertions(+), 14 deletions(-) create mode 100644 appview/pages/templates/spindles/dashboard.html create mode 100644 appview/pages/templates/spindles/fragments/addMemberModal.html diff --git a/appview/db/repos.go b/appview/db/repos.go index 1e7072ee..c28f206c 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "fmt" + "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" @@ -70,6 +71,192 @@ func GetAllRepos(e Execer, limit int) ([]Repo, error) { return repos, nil } +func GetRepos(e Execer, filters ...filter) ([]Repo, error) { + repoMap := make(map[syntax.ATURI]Repo) + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + repoQuery := fmt.Sprintf( + `select + did, + name, + knot, + rkey, + created, + description, + source, + spindle + from + repos r + %s`, + whereClause, + ) + rows, err := e.Query(repoQuery, args...) + + if err != nil { + return nil, fmt.Errorf("failed to execute repo query: %w ", err) + } + + for rows.Next() { + var repo Repo + var createdAt string + var description, source, spindle sql.NullString + + err := rows.Scan( + &repo.Did, + &repo.Name, + &repo.Knot, + &repo.Rkey, + &createdAt, + &description, + &source, + &spindle, + ) + if err != nil { + return nil, fmt.Errorf("failed to execute repo query: %w ", err) + } + + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + repo.Created = t + } + if description.Valid { + repo.Description = description.String + } + if source.Valid { + repo.Source = source.String + } + if spindle.Valid { + repo.Spindle = spindle.String + } + + repoMap[repo.RepoAt()] = repo + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("failed to execute repo query: %w ", err) + } + + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") + args = make([]any, len(repoMap)) + for _, r := range repoMap { + args = append(args, r.RepoAt()) + } + + starCountQuery := fmt.Sprintf( + `select + repo_at, count(1) + from stars + where repo_at in (%s) + group by repo_at`, + inClause, + ) + rows, err = e.Query(starCountQuery, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) + } + for rows.Next() { + var repoat string + var count int + if err := rows.Scan(&repoat, &count); err != nil { + continue + } + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { + r.RepoStats.StarCount = count + } + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) + } + + issueCountQuery := fmt.Sprintf( + `select + repo_at, + count(case when open = 1 then 1 end) as open_count, + count(case when open = 0 then 1 end) as closed_count + from issues + where repo_at in (%s) + group by repo_at`, + inClause, + ) + rows, err = e.Query(issueCountQuery, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) + } + for rows.Next() { + var repoat string + var open, closed int + if err := rows.Scan(&repoat, &open, &closed); err != nil { + continue + } + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { + r.RepoStats.IssueCount.Open = open + r.RepoStats.IssueCount.Closed = closed + } + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) + } + + pullCountQuery := fmt.Sprintf( + `select + repo_at, + count(case when state = ? then 1 end) as open_count, + count(case when state = ? then 1 end) as merged_count, + count(case when state = ? then 1 end) as closed_count, + count(case when state = ? then 1 end) as deleted_count + from pulls + where repo_at in (%s) + group by repo_at`, + inClause, + ) + args = append([]any{ + PullOpen, + PullMerged, + PullClosed, + PullDeleted, + }, args...) + rows, err = e.Query( + pullCountQuery, + args..., + ) + if err != nil { + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) + } + for rows.Next() { + var repoat string + var open, merged, closed, deleted int + if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { + continue + } + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { + r.RepoStats.PullCount.Open = open + r.RepoStats.PullCount.Merged = merged + r.RepoStats.PullCount.Closed = closed + r.RepoStats.PullCount.Deleted = deleted + } + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) + } + + var repos []Repo + for _, r := range repoMap { + repos = append(repos, r) + } + + return repos, nil +} + func GetAllReposByDid(e Execer, did string) ([]Repo, error) { var repos []Repo diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 8ef647eb..327fad36 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -301,12 +301,23 @@ func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { } type SpindleListingParams struct { + db.Spindle +} + +func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { + return p.executePlain("spindles/fragments/spindleListing", w, params) +} + +type SpindleDashboardParams struct { LoggedInUser *oauth.User Spindle db.Spindle + Members []string + Repos map[string][]db.Repo + DidHandleMap map[string]string } -func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { - return p.execute("spindles/fragments/spindleListing", w, params) +func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { + return p.execute("spindles/dashboard", w, params) } type NewRepoParams struct { diff --git a/appview/pages/templates/spindles/dashboard.html b/appview/pages/templates/spindles/dashboard.html new file mode 100644 index 00000000..77ed1a92 --- /dev/null +++ b/appview/pages/templates/spindles/dashboard.html @@ -0,0 +1,119 @@ +{{ define "title" }}{{.Spindle.Instance}} · spindles{{ end }} + +{{ define "content" }} +
+
+

{{ .Spindle.Instance }}

+
+ {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} + {{ if .Spindle.Verified }} + {{ i "shield-check" "w-4 h-4" }} verified + {{ if $isOwner }} + {{ template "spindles/fragments/addMemberModal" .Spindle }} + {{ end }} + {{ else }} + {{ i "shield-off" "w-4 h-4" }} unverified + {{ if $isOwner }} + {{ block "retryButton" .Spindle }} {{ end }} + {{ end }} + {{ end }} + + {{ if $isOwner }} + {{ block "deleteButton" .Spindle }} {{ end }} + {{ end }} +
+
+
+
+ +{{ if .Members }} +
+
+ {{ block "member" . }} {{ end }} +
+
+{{ end }} +{{ end }} + + +{{ define "member" }} + {{ range .Members }} +
+
+
+ {{ i "user" "size-4" }} + {{ $user := index $.DidHandleMap . }} + {{ $user }} +
+ {{ if ne $.LoggedInUser.Did . }} + {{ block "removeMemberButton" (list $ . ) }} {{ end }} + {{ end }} +
+
+ {{ $repos := index $.Repos . }} + {{ range $repos }} +
+ {{ i "book-marked" "size-4" }} + + {{ .Name }} + +
+ {{ else }} +
+ No repositories configured yet. +
+ {{ end }} +
+
+ {{ end }} +{{ end }} + +{{ define "deleteButton" }} + +{{ end }} + + +{{ define "retryButton" }} + +{{ end }} + + +{{ define "removeMemberButton" }} + {{ $root := index . 0 }} + {{ $member := index . 1 }} + +{{ end }} + diff --git a/appview/pages/templates/spindles/fragments/addMemberModal.html b/appview/pages/templates/spindles/fragments/addMemberModal.html new file mode 100644 index 00000000..a293f802 --- /dev/null +++ b/appview/pages/templates/spindles/fragments/addMemberModal.html @@ -0,0 +1,57 @@ +{{ define "spindles/fragments/addMemberModal" }} + + +
+ {{ block "addMemberPopover" . }} {{ end }} +
+{{ end }} + +{{ define "addMemberPopover" }} +
+ +

Members can register repositories and run workflows on this spindle.

+ +
+ + +
+
+
+{{ end }} diff --git a/appview/pages/templates/spindles/fragments/spindleListing.html b/appview/pages/templates/spindles/fragments/spindleListing.html index f78ffd8b..71a5e73a 100644 --- a/appview/pages/templates/spindles/fragments/spindleListing.html +++ b/appview/pages/templates/spindles/fragments/spindleListing.html @@ -1,23 +1,42 @@ {{ define "spindles/fragments/spindleListing" }} -
-
+
+ {{ block "leftSide" . }} {{ end }} + {{ block "rightSide" . }} {{ end }} +
+{{ end }} + +{{ define "leftSide" }} + {{ if .Verified }} + + {{ i "hard-drive" "w-4 h-4" }} + {{ .Instance }} + + {{ .Created | shortTimeFmt }} ago + + + {{ else }} +
{{ i "hard-drive" "w-4 h-4" }} {{ .Instance }} {{ .Created | shortTimeFmt }} ago
+ {{ end }} +{{ end }} + +{{ define "rightSide" }}
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} {{ if .Verified }} {{ i "shield-check" "w-4 h-4" }} verified + {{ template "spindles/fragments/addMemberModal" . }} {{ else }} {{ i "shield-off" "w-4 h-4" }} unverified {{ block "retryButton" . }} {{ end }} {{ end }} {{ block "deleteButton" . }} {{ end }}
-
{{ end }} {{ define "deleteButton" }} diff --git a/appview/pages/templates/spindles/index.html b/appview/pages/templates/spindles/index.html index 0cb89525..3ca97f13 100644 --- a/appview/pages/templates/spindles/index.html +++ b/appview/pages/templates/spindles/index.html @@ -25,7 +25,7 @@
{{ end }} -
+
{{ end }} @@ -36,7 +36,7 @@
@@ -50,13 +50,14 @@ > diff --git a/appview/state/router.go b/appview/state/router.go index 6849130d..da986e31 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -178,12 +178,13 @@ func (s *State) SpindlesRouter() http.Handler { logger := log.New("spindles") spindles := &spindles.Spindles{ - Db: s.db, - OAuth: s.oauth, - Pages: s.pages, - Config: s.config, - Enforcer: s.enforcer, - Logger: logger, + Db: s.db, + OAuth: s.oauth, + Pages: s.pages, + Config: s.config, + Enforcer: s.enforcer, + IdResolver: s.idResolver, + Logger: logger, } return spindles.Router() -- 2.51.2