Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

appview/pages: add spindle dashboard UI

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li a3bce1c1 fce55b57

verified
+410 -15
+187
appview/db/repos.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 66 65 67 66 if err := rows.Err(); err != nil { 68 67 return nil, err 68 + } 69 + 70 + return repos, nil 71 + } 72 + 73 + func GetRepos(e Execer, filters ...filter) ([]Repo, error) { 74 + repoMap := make(map[syntax.ATURI]Repo) 75 + 76 + var conditions []string 77 + var args []any 78 + for _, filter := range filters { 79 + conditions = append(conditions, filter.Condition()) 80 + args = append(args, filter.Arg()...) 81 + } 82 + 83 + whereClause := "" 84 + if conditions != nil { 85 + whereClause = " where " + strings.Join(conditions, " and ") 86 + } 87 + 88 + repoQuery := fmt.Sprintf( 89 + `select 90 + did, 91 + name, 92 + knot, 93 + rkey, 94 + created, 95 + description, 96 + source, 97 + spindle 98 + from 99 + repos r 100 + %s`, 101 + whereClause, 102 + ) 103 + rows, err := e.Query(repoQuery, args...) 104 + 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 107 + } 108 + 109 + for rows.Next() { 110 + var repo Repo 111 + var createdAt string 112 + var description, source, spindle sql.NullString 113 + 114 + err := rows.Scan( 115 + &repo.Did, 116 + &repo.Name, 117 + &repo.Knot, 118 + &repo.Rkey, 119 + &createdAt, 120 + &description, 121 + &source, 122 + &spindle, 123 + ) 124 + if err != nil { 125 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 126 + } 127 + 128 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 129 + repo.Created = t 130 + } 131 + if description.Valid { 132 + repo.Description = description.String 133 + } 134 + if source.Valid { 135 + repo.Source = source.String 136 + } 137 + if spindle.Valid { 138 + repo.Spindle = spindle.String 139 + } 140 + 141 + repoMap[repo.RepoAt()] = repo 142 + } 143 + 144 + if err = rows.Err(); err != nil { 145 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 146 + } 147 + 148 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 149 + args = make([]any, len(repoMap)) 150 + for _, r := range repoMap { 151 + args = append(args, r.RepoAt()) 152 + } 153 + 154 + starCountQuery := fmt.Sprintf( 155 + `select 156 + repo_at, count(1) 157 + from stars 158 + where repo_at in (%s) 159 + group by repo_at`, 160 + inClause, 161 + ) 162 + rows, err = e.Query(starCountQuery, args...) 163 + if err != nil { 164 + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 165 + } 166 + for rows.Next() { 167 + var repoat string 168 + var count int 169 + if err := rows.Scan(&repoat, &count); err != nil { 170 + continue 171 + } 172 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 173 + r.RepoStats.StarCount = count 174 + } 175 + } 176 + if err = rows.Err(); err != nil { 177 + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 178 + } 179 + 180 + issueCountQuery := fmt.Sprintf( 181 + `select 182 + repo_at, 183 + count(case when open = 1 then 1 end) as open_count, 184 + count(case when open = 0 then 1 end) as closed_count 185 + from issues 186 + where repo_at in (%s) 187 + group by repo_at`, 188 + inClause, 189 + ) 190 + rows, err = e.Query(issueCountQuery, args...) 191 + if err != nil { 192 + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 193 + } 194 + for rows.Next() { 195 + var repoat string 196 + var open, closed int 197 + if err := rows.Scan(&repoat, &open, &closed); err != nil { 198 + continue 199 + } 200 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 201 + r.RepoStats.IssueCount.Open = open 202 + r.RepoStats.IssueCount.Closed = closed 203 + } 204 + } 205 + if err = rows.Err(); err != nil { 206 + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 207 + } 208 + 209 + pullCountQuery := fmt.Sprintf( 210 + `select 211 + repo_at, 212 + count(case when state = ? then 1 end) as open_count, 213 + count(case when state = ? then 1 end) as merged_count, 214 + count(case when state = ? then 1 end) as closed_count, 215 + count(case when state = ? then 1 end) as deleted_count 216 + from pulls 217 + where repo_at in (%s) 218 + group by repo_at`, 219 + inClause, 220 + ) 221 + args = append([]any{ 222 + PullOpen, 223 + PullMerged, 224 + PullClosed, 225 + PullDeleted, 226 + }, args...) 227 + rows, err = e.Query( 228 + pullCountQuery, 229 + args..., 230 + ) 231 + if err != nil { 232 + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 233 + } 234 + for rows.Next() { 235 + var repoat string 236 + var open, merged, closed, deleted int 237 + if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 238 + continue 239 + } 240 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 241 + r.RepoStats.PullCount.Open = open 242 + r.RepoStats.PullCount.Merged = merged 243 + r.RepoStats.PullCount.Closed = closed 244 + r.RepoStats.PullCount.Deleted = deleted 245 + } 246 + } 247 + if err = rows.Err(); err != nil { 248 + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 249 + } 250 + 251 + var repos []Repo 252 + for _, r := range repoMap { 253 + repos = append(repos, r) 69 254 } 70 255 71 256 return repos, nil
+14 -3
appview/pages/pages.go
··· 301 301 } 302 302 303 303 type SpindleListingParams struct { 304 - LoggedInUser *oauth.User 305 - Spindle db.Spindle 304 + db.Spindle 306 305 } 307 306 308 307 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 309 - return p.execute("spindles/fragments/spindleListing", w, params) 308 + return p.executePlain("spindles/fragments/spindleListing", w, params) 309 + } 310 + 311 + type SpindleDashboardParams struct { 312 + LoggedInUser *oauth.User 313 + Spindle db.Spindle 314 + Members []string 315 + Repos map[string][]db.Repo 316 + DidHandleMap map[string]string 317 + } 318 + 319 + func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 320 + return p.execute("spindles/dashboard", w, params) 310 321 } 311 322 312 323 type NewRepoParams struct {
+119
appview/pages/templates/spindles/dashboard.html
··· 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} 10 + {{ if .Spindle.Verified }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 13 + {{ template "spindles/fragments/addMemberModal" .Spindle }} 14 + {{ end }} 15 + {{ else }} 16 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 17 + {{ if $isOwner }} 18 + {{ block "retryButton" .Spindle }} {{ end }} 19 + {{ end }} 20 + {{ end }} 21 + 22 + {{ if $isOwner }} 23 + {{ block "deleteButton" .Spindle }} {{ end }} 24 + {{ end }} 25 + </div> 26 + </div> 27 + <div id="operation-error" class="dark:text-red-400"></div> 28 + </div> 29 + 30 + {{ if .Members }} 31 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 32 + <div class="flex flex-col gap-2"> 33 + {{ block "member" . }} {{ end }} 34 + </div> 35 + </section> 36 + {{ end }} 37 + {{ end }} 38 + 39 + 40 + {{ define "member" }} 41 + {{ range .Members }} 42 + <div> 43 + <div class="flex justify-between items-center"> 44 + <div class="flex items-center gap-2"> 45 + {{ i "user" "size-4" }} 46 + {{ $user := index $.DidHandleMap . }} 47 + <a href="/{{ $user }}">{{ $user }}</a> 48 + </div> 49 + {{ if ne $.LoggedInUser.Did . }} 50 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 51 + {{ end }} 52 + </div> 53 + <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 54 + {{ $repos := index $.Repos . }} 55 + {{ range $repos }} 56 + <div class="flex gap-2 items-center"> 57 + {{ i "book-marked" "size-4" }} 58 + <a href="/{{ .Did }}/{{ .Name }}"> 59 + {{ .Name }} 60 + </a> 61 + </div> 62 + {{ else }} 63 + <div class="text-gray-500 dark:text-gray-400"> 64 + No repositories configured yet. 65 + </div> 66 + {{ end }} 67 + </div> 68 + </div> 69 + {{ end }} 70 + {{ end }} 71 + 72 + {{ define "deleteButton" }} 73 + <button 74 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 75 + title="Delete spindle" 76 + hx-delete="/spindles/{{ .Instance }}" 77 + hx-swap="outerHTML" 78 + hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 79 + hx-headers='{"shouldRedirect": "true"}' 80 + > 81 + {{ i "trash-2" "w-5 h-5" }} 82 + <span class="hidden md:inline">delete</span> 83 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 84 + </button> 85 + {{ end }} 86 + 87 + 88 + {{ define "retryButton" }} 89 + <button 90 + class="btn gap-2 group" 91 + title="Retry spindle verification" 92 + hx-post="/spindles/{{ .Instance }}/retry" 93 + hx-swap="none" 94 + hx-headers='{"shouldRefresh": "true"}' 95 + > 96 + {{ i "rotate-ccw" "w-5 h-5" }} 97 + <span class="hidden md:inline">retry</span> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </button> 100 + {{ end }} 101 + 102 + 103 + {{ define "removeMemberButton" }} 104 + {{ $root := index . 0 }} 105 + {{ $member := index . 1 }} 106 + <button 107 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 108 + title="Remove member" 109 + hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 110 + hx-swap="none" 111 + hx-vals='{"member": "{{$member}}" }' 112 + hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?" 113 + > 114 + {{ i "user-minus" "w-4 h-4" }} 115 + remove 116 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 117 + </button> 118 + {{ end }} 119 +
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 1 + {{ define "spindles/fragments/addMemberModal" }} 2 + <button 3 + class="btn gap-2 group" 4 + title="Add member to this spindle" 5 + popovertarget="add-member-{{ .Instance }}" 6 + popovertargetaction="toggle" 7 + > 8 + {{ i "user-plus" "w-5 h-5" }} 9 + <span class="hidden md:inline">add member</span> 10 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 11 + </button> 12 + 13 + <div 14 + id="add-member-{{ .Instance }}" 15 + popover 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 17 + {{ block "addMemberPopover" . }} {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "addMemberPopover" }} 22 + <form 23 + hx-post="/spindles/{{ .Instance }}/add" 24 + hx-indicator="#spinner" 25 + hx-swap="none" 26 + class="flex flex-col gap-2" 27 + > 28 + <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 + ADD MEMBER 30 + </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="member" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 39 + <div class="flex gap-2 pt-2"> 40 + <button 41 + type="button" 42 + popovertarget="add-member-{{ .Instance }}" 43 + popovertargetaction="hide" 44 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 45 + > 46 + {{ i "x" "size-4" }} cancel 47 + </button> 48 + <button type="submit" class="btn w-1/2 flex items-center"> 49 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 50 + <span id="spinner" class="group"> 51 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </span> 53 + </button> 54 + </div> 55 + <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 + </form> 57 + {{ end }}
+22 -3
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 1 {{ define "spindles/fragments/spindleListing" }} 2 - <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 2 + <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "leftSide" . }} {{ end }} 4 + {{ block "rightSide" . }} {{ end }} 5 + </div> 6 + {{ end }} 7 + 8 + {{ define "leftSide" }} 9 + {{ if .Verified }} 10 + <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + {{ .Instance }} 13 + <span class="text-gray-500"> 14 + {{ .Created | shortTimeFmt }} ago 15 + </span> 16 + </a> 17 + {{ else }} 18 + <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 4 19 {{ i "hard-drive" "w-4 h-4" }} 5 20 {{ .Instance }} 6 21 <span class="text-gray-500"> 7 22 {{ .Created | shortTimeFmt }} ago 8 23 </span> 9 24 </div> 25 + {{ end }} 26 + {{ end }} 27 + 28 + {{ define "rightSide" }} 10 29 <div id="right-side" class="flex gap-2"> 11 30 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 12 31 {{ if .Verified }} 13 32 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 33 + {{ template "spindles/fragments/addMemberModal" . }} 14 34 {{ else }} 15 35 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 16 36 {{ block "retryButton" . }} {{ end }} 17 37 {{ end }} 18 38 {{ block "deleteButton" . }} {{ end }} 19 39 </div> 20 - </div> 21 40 {{ end }} 22 41 23 42 {{ define "deleteButton" }}
+4 -3
appview/pages/templates/spindles/index.html
··· 25 25 </div> 26 26 {{ end }} 27 27 </div> 28 - <div id="operation-error" class="dark:text-red-400"></div> 28 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 29 29 </section> 30 30 {{ end }} 31 31 ··· 36 36 <form 37 37 hx-post="/spindles/register" 38 38 class="max-w-2xl mb-2 space-y-4" 39 - hx-indicator="#register-spinner" 39 + hx-indicator="#register-button" 40 40 hx-swap="none" 41 41 > 42 42 <div class="flex gap-2"> ··· 50 50 > 51 51 <button 52 52 type="submit" 53 + id="register-button" 53 54 class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 54 55 > 55 56 <span class="inline-flex items-center gap-2"> 56 57 {{ i "plus" "w-4 h-4" }} 57 58 register 58 59 </span> 59 - <span id="register-spinner" class="pl-2 hidden group-[.htmx-request]:inline"> 60 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 60 61 {{ i "loader-circle" "w-4 h-4 animate-spin" }} 61 62 </span> 62 63 </button>
+7 -6
appview/state/router.go
··· 178 178 logger := log.New("spindles") 179 179 180 180 spindles := &spindles.Spindles{ 181 - Db: s.db, 182 - OAuth: s.oauth, 183 - Pages: s.pages, 184 - Config: s.config, 185 - Enforcer: s.enforcer, 186 - Logger: logger, 181 + Db: s.db, 182 + OAuth: s.oauth, 183 + Pages: s.pages, 184 + Config: s.config, 185 + Enforcer: s.enforcer, 186 + IdResolver: s.idResolver, 187 + Logger: logger, 187 188 } 188 189 189 190 return spindles.Router()