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
···
import (
"database/sql"
"fmt"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
if err := rows.Err(); err != nil {
return nil, err
+
}
+
+
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
+13 -2
appview/pages/pages.go
···
}
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 {
+119
appview/pages/templates/spindles/dashboard.html
···
+
{{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }}
+
+
{{ define "content" }}
+
<div class="px-6 py-4">
+
<div class="flex justify-between items-center">
+
<h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1>
+
<div id="right-side" class="flex gap-2">
+
{{ $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 }}
+
<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>
+
{{ if $isOwner }}
+
{{ template "spindles/fragments/addMemberModal" .Spindle }}
+
{{ end }}
+
{{ else }}
+
<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>
+
{{ if $isOwner }}
+
{{ block "retryButton" .Spindle }} {{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ if $isOwner }}
+
{{ block "deleteButton" .Spindle }} {{ end }}
+
{{ end }}
+
</div>
+
</div>
+
<div id="operation-error" class="dark:text-red-400"></div>
+
</div>
+
+
{{ if .Members }}
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="flex flex-col gap-2">
+
{{ block "member" . }} {{ end }}
+
</div>
+
</section>
+
{{ end }}
+
{{ end }}
+
+
+
{{ define "member" }}
+
{{ range .Members }}
+
<div>
+
<div class="flex justify-between items-center">
+
<div class="flex items-center gap-2">
+
{{ i "user" "size-4" }}
+
{{ $user := index $.DidHandleMap . }}
+
<a href="/{{ $user }}">{{ $user }}</a>
+
</div>
+
{{ if ne $.LoggedInUser.Did . }}
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
+
{{ end }}
+
</div>
+
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
+
{{ $repos := index $.Repos . }}
+
{{ range $repos }}
+
<div class="flex gap-2 items-center">
+
{{ i "book-marked" "size-4" }}
+
<a href="/{{ .Did }}/{{ .Name }}">
+
{{ .Name }}
+
</a>
+
</div>
+
{{ else }}
+
<div class="text-gray-500 dark:text-gray-400">
+
No repositories configured yet.
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ define "deleteButton" }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete spindle"
+
hx-delete="/spindles/{{ .Instance }}"
+
hx-swap="outerHTML"
+
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
+
hx-headers='{"shouldRedirect": "true"}'
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "retryButton" }}
+
<button
+
class="btn gap-2 group"
+
title="Retry spindle verification"
+
hx-post="/spindles/{{ .Instance }}/retry"
+
hx-swap="none"
+
hx-headers='{"shouldRefresh": "true"}'
+
>
+
{{ i "rotate-ccw" "w-5 h-5" }}
+
<span class="hidden md:inline">retry</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "removeMemberButton" }}
+
{{ $root := index . 0 }}
+
{{ $member := index . 1 }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Remove member"
+
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
+
hx-swap="none"
+
hx-vals='{"member": "{{$member}}" }'
+
hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?"
+
>
+
{{ i "user-minus" "w-4 h-4" }}
+
remove
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
···
+
{{ define "spindles/fragments/addMemberModal" }}
+
<button
+
class="btn gap-2 group"
+
title="Add member to this spindle"
+
popovertarget="add-member-{{ .Instance }}"
+
popovertargetaction="toggle"
+
>
+
{{ i "user-plus" "w-5 h-5" }}
+
<span class="hidden md:inline">add member</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
+
<div
+
id="add-member-{{ .Instance }}"
+
popover
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
+
{{ block "addMemberPopover" . }} {{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "addMemberPopover" }}
+
<form
+
hx-post="/spindles/{{ .Instance }}/add"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
class="flex flex-col gap-2"
+
>
+
<label for="member-did-{{ .Id }}" class="uppercase p-0">
+
ADD MEMBER
+
</label>
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
+
<input
+
type="text"
+
id="member-did-{{ .Id }}"
+
name="member"
+
required
+
placeholder="@foo.bsky.social"
+
/>
+
<div class="flex gap-2 pt-2">
+
<button
+
type="button"
+
popovertarget="add-member-{{ .Instance }}"
+
popovertargetaction="hide"
+
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"
+
>
+
{{ i "x" "size-4" }} cancel
+
</button>
+
<button type="submit" class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ end }}
+22 -3
appview/pages/templates/spindles/fragments/spindleListing.html
···
{{ define "spindles/fragments/spindleListing" }}
-
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
-
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
+
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
+
{{ block "leftSide" . }} {{ end }}
+
{{ block "rightSide" . }} {{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "leftSide" }}
+
{{ if .Verified }}
+
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
+
{{ i "hard-drive" "w-4 h-4" }}
+
{{ .Instance }}
+
<span class="text-gray-500">
+
{{ .Created | shortTimeFmt }} ago
+
</span>
+
</a>
+
{{ else }}
+
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
{{ i "hard-drive" "w-4 h-4" }}
{{ .Instance }}
<span class="text-gray-500">
{{ .Created | shortTimeFmt }} ago
</span>
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ define "rightSide" }}
<div id="right-side" class="flex gap-2">
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
{{ if .Verified }}
<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>
+
{{ template "spindles/fragments/addMemberModal" . }}
{{ else }}
<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>
{{ block "retryButton" . }} {{ end }}
{{ end }}
{{ block "deleteButton" . }} {{ end }}
</div>
-
</div>
{{ end }}
{{ define "deleteButton" }}
+4 -3
appview/pages/templates/spindles/index.html
···
</div>
{{ end }}
</div>
-
<div id="operation-error" class="dark:text-red-400"></div>
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
</section>
{{ end }}
···
<form
hx-post="/spindles/register"
class="max-w-2xl mb-2 space-y-4"
-
hx-indicator="#register-spinner"
+
hx-indicator="#register-button"
hx-swap="none"
>
<div class="flex gap-2">
···
>
<button
type="submit"
+
id="register-button"
class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
>
<span class="inline-flex items-center gap-2">
{{ i "plus" "w-4 h-4" }}
register
</span>
-
<span id="register-spinner" class="pl-2 hidden group-[.htmx-request]:inline">
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
</span>
</button>
+7 -6
appview/state/router.go
···
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()