forked from tangled.org/core
this repo has no description

appview/pages: add spindle dashboard UI

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

oppi.li a3bce1c1 fce55b57

verified
Changed files
+409 -14
appview
+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" ··· 65 66 66 67 if err := rows.Err(); err != nil { 67 68 return nil, err 69 + } 70 + 71 + return repos, nil 72 + } 73 + 74 + func GetRepos(e Execer, filters ...filter) ([]Repo, error) { 75 + repoMap := make(map[syntax.ATURI]Repo) 76 + 77 + var conditions []string 78 + var args []any 79 + for _, filter := range filters { 80 + conditions = append(conditions, filter.Condition()) 81 + args = append(args, filter.Arg()...) 82 + } 83 + 84 + whereClause := "" 85 + if conditions != nil { 86 + whereClause = " where " + strings.Join(conditions, " and ") 87 + } 88 + 89 + repoQuery := fmt.Sprintf( 90 + `select 91 + did, 92 + name, 93 + knot, 94 + rkey, 95 + created, 96 + description, 97 + source, 98 + spindle 99 + from 100 + repos r 101 + %s`, 102 + whereClause, 103 + ) 104 + rows, err := e.Query(repoQuery, args...) 105 + 106 + if err != nil { 107 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 108 + } 109 + 110 + for rows.Next() { 111 + var repo Repo 112 + var createdAt string 113 + var description, source, spindle sql.NullString 114 + 115 + err := rows.Scan( 116 + &repo.Did, 117 + &repo.Name, 118 + &repo.Knot, 119 + &repo.Rkey, 120 + &createdAt, 121 + &description, 122 + &source, 123 + &spindle, 124 + ) 125 + if err != nil { 126 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 127 + } 128 + 129 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 130 + repo.Created = t 131 + } 132 + if description.Valid { 133 + repo.Description = description.String 134 + } 135 + if source.Valid { 136 + repo.Source = source.String 137 + } 138 + if spindle.Valid { 139 + repo.Spindle = spindle.String 140 + } 141 + 142 + repoMap[repo.RepoAt()] = repo 143 + } 144 + 145 + if err = rows.Err(); err != nil { 146 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 147 + } 148 + 149 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 150 + args = make([]any, len(repoMap)) 151 + for _, r := range repoMap { 152 + args = append(args, r.RepoAt()) 153 + } 154 + 155 + starCountQuery := fmt.Sprintf( 156 + `select 157 + repo_at, count(1) 158 + from stars 159 + where repo_at in (%s) 160 + group by repo_at`, 161 + inClause, 162 + ) 163 + rows, err = e.Query(starCountQuery, args...) 164 + if err != nil { 165 + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 166 + } 167 + for rows.Next() { 168 + var repoat string 169 + var count int 170 + if err := rows.Scan(&repoat, &count); err != nil { 171 + continue 172 + } 173 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 174 + r.RepoStats.StarCount = count 175 + } 176 + } 177 + if err = rows.Err(); err != nil { 178 + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 179 + } 180 + 181 + issueCountQuery := fmt.Sprintf( 182 + `select 183 + repo_at, 184 + count(case when open = 1 then 1 end) as open_count, 185 + count(case when open = 0 then 1 end) as closed_count 186 + from issues 187 + where repo_at in (%s) 188 + group by repo_at`, 189 + inClause, 190 + ) 191 + rows, err = e.Query(issueCountQuery, args...) 192 + if err != nil { 193 + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 194 + } 195 + for rows.Next() { 196 + var repoat string 197 + var open, closed int 198 + if err := rows.Scan(&repoat, &open, &closed); err != nil { 199 + continue 200 + } 201 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 202 + r.RepoStats.IssueCount.Open = open 203 + r.RepoStats.IssueCount.Closed = closed 204 + } 205 + } 206 + if err = rows.Err(); err != nil { 207 + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 208 + } 209 + 210 + pullCountQuery := fmt.Sprintf( 211 + `select 212 + repo_at, 213 + count(case when state = ? then 1 end) as open_count, 214 + count(case when state = ? then 1 end) as merged_count, 215 + count(case when state = ? then 1 end) as closed_count, 216 + count(case when state = ? then 1 end) as deleted_count 217 + from pulls 218 + where repo_at in (%s) 219 + group by repo_at`, 220 + inClause, 221 + ) 222 + args = append([]any{ 223 + PullOpen, 224 + PullMerged, 225 + PullClosed, 226 + PullDeleted, 227 + }, args...) 228 + rows, err = e.Query( 229 + pullCountQuery, 230 + args..., 231 + ) 232 + if err != nil { 233 + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 234 + } 235 + for rows.Next() { 236 + var repoat string 237 + var open, merged, closed, deleted int 238 + if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 239 + continue 240 + } 241 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 242 + r.RepoStats.PullCount.Open = open 243 + r.RepoStats.PullCount.Merged = merged 244 + r.RepoStats.PullCount.Closed = closed 245 + r.RepoStats.PullCount.Deleted = deleted 246 + } 247 + } 248 + if err = rows.Err(); err != nil { 249 + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 250 + } 251 + 252 + var repos []Repo 253 + for _, r := range repoMap { 254 + repos = append(repos, r) 68 255 } 69 256 70 257 return repos, nil
+13 -2
appview/pages/pages.go
··· 301 301 } 302 302 303 303 type SpindleListingParams struct { 304 + db.Spindle 305 + } 306 + 307 + func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 308 + return p.executePlain("spindles/fragments/spindleListing", w, params) 309 + } 310 + 311 + type SpindleDashboardParams struct { 304 312 LoggedInUser *oauth.User 305 313 Spindle db.Spindle 314 + Members []string 315 + Repos map[string][]db.Repo 316 + DidHandleMap map[string]string 306 317 } 307 318 308 - func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 309 - return p.execute("spindles/fragments/spindleListing", w, params) 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()