+46
-3
appview/db/pipeline.go
+46
-3
appview/db/pipeline.go
···
7
7
"time"
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/go-git/go-git/v5/plumbing"
10
11
spindle "tangled.sh/tangled.sh/core/spindle/models"
11
12
)
12
13
···
60
61
return m
61
62
}
62
63
64
+
func (p Pipeline) TimeTaken() time.Duration {
65
+
var s time.Duration
66
+
for _, w := range p.Statuses {
67
+
s += w.TimeTaken()
68
+
}
69
+
return s
70
+
}
71
+
72
+
func (p Pipeline) Workflows() []string {
73
+
var ws []string
74
+
for v := range p.Statuses {
75
+
ws = append(ws, v)
76
+
}
77
+
slices.Sort(ws)
78
+
return ws
79
+
}
80
+
63
81
type Trigger struct {
64
82
Id int
65
83
Kind string
···
76
94
PRAction *string
77
95
}
78
96
97
+
func (t *Trigger) IsPush() bool {
98
+
return t != nil && t.Kind == "push"
99
+
}
100
+
101
+
func (t *Trigger) IsPullRequest() bool {
102
+
return t != nil && t.Kind == "pull_request"
103
+
}
104
+
105
+
func (t *Trigger) TargetRef() string {
106
+
if t.IsPush() {
107
+
return plumbing.ReferenceName(*t.PushRef).Short()
108
+
} else if t.IsPullRequest() {
109
+
return *t.PRTargetBranch
110
+
}
111
+
112
+
return ""
113
+
}
114
+
79
115
type PipelineStatus struct {
80
116
ID int
81
117
Spindle string
···
262
298
263
299
query := fmt.Sprintf(`
264
300
select
265
-
p.id AS pipeline_id,
301
+
p.id,
266
302
p.knot,
267
303
p.rkey,
268
304
p.repo_owner,
269
305
p.repo_name,
270
306
p.sha,
271
307
p.created,
272
-
t.id AS trigger_id,
308
+
t.id,
273
309
t.kind,
274
310
t.push_ref,
275
311
t.push_new_sha,
···
283
319
join
284
320
triggers t ON p.trigger_id = t.id
285
321
%s
286
-
order by p.created desc
287
322
`, whereClause)
288
323
289
324
rows, err := e.Query(query, args...)
···
424
459
}
425
460
all = append(all, p)
426
461
}
462
+
463
+
// sort pipelines by date
464
+
slices.SortFunc(all, func(a, b Pipeline) int {
465
+
if a.Created.After(b.Created) {
466
+
return -1
467
+
}
468
+
return 1
469
+
})
427
470
428
471
return all, nil
429
472
}
+7
appview/pages/funcmap.go
+7
appview/pages/funcmap.go
···
225
225
}
226
226
return dict, nil
227
227
},
228
+
"deref": func(v any) any {
229
+
val := reflect.ValueOf(v)
230
+
if val.Kind() == reflect.Ptr && !val.IsNil() {
231
+
return val.Elem().Interface()
232
+
}
233
+
return nil
234
+
},
228
235
"i": func(name string, classes ...string) template.HTML {
229
236
data, err := icon(name, classes)
230
237
if err != nil {
+12
appview/pages/pages.go
+12
appview/pages/pages.go
···
951
951
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
952
952
}
953
953
954
+
type PipelinesParams struct {
955
+
LoggedInUser *oauth.User
956
+
RepoInfo repoinfo.RepoInfo
957
+
Pipelines []db.Pipeline
958
+
Active string
959
+
}
960
+
961
+
func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
962
+
params.Active = "pipelines"
963
+
return p.executeRepo("repo/pipelines/pipelines", w, params)
964
+
}
965
+
954
966
func (p *Pages) Static() http.Handler {
955
967
if p.dev {
956
968
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
+1
appview/pages/repoinfo/repoinfo.go
+1
appview/pages/repoinfo/repoinfo.go
+1
-1
appview/pages/templates/repo/commit.html
+1
-1
appview/pages/templates/repo/commit.html
-119
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
-119
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
···
1
-
{{ define "repo/fragments/pipelineStatusSymbol" }}
2
-
<div class="group relative inline-block">
3
-
{{ block "icon" $ }} {{ end }}
4
-
{{ block "tooltip" $ }} {{ end }}
5
-
</div>
6
-
{{ end }}
7
-
8
-
{{ define "icon" }}
9
-
<div class="cursor-pointer">
10
-
{{ $c := .Counts }}
11
-
{{ $statuses := .Statuses }}
12
-
{{ $total := len $statuses }}
13
-
{{ $success := index $c "success" }}
14
-
{{ $allPass := eq $success $total }}
15
-
16
-
{{ if $allPass }}
17
-
<div class="flex gap-1 items-center">
18
-
{{ i "check" "size-4 text-green-600 dark:text-green-400 " }}
19
-
<span>{{ $total }}/{{ $total }}</span>
20
-
</div>
21
-
{{ else }}
22
-
{{ $radius := f64 8 }}
23
-
{{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }}
24
-
{{ $offset := 0.0 }}
25
-
<div class="flex gap-1 items-center">
26
-
<svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20">
27
-
<circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/>
28
-
29
-
{{ range $kind, $count := $c }}
30
-
{{ $color := "" }}
31
-
{{ if or (eq $kind "pending") (eq $kind "running") }}
32
-
{{ $color = "#eab308" }}
33
-
{{ else if eq $kind "success" }}
34
-
{{ $color = "#10b981" }}
35
-
{{ else if eq $kind "cancelled" }}
36
-
{{ $color = "#6b7280" }}
37
-
{{ else }}
38
-
{{ $color = "#ef4444" }}
39
-
{{ end }}
40
-
41
-
{{ $percent := divf64 (f64 $count) (f64 $total) }}
42
-
{{ $length := mulf64 $percent $circumference }}
43
-
44
-
<circle
45
-
cx="10" cy="10" r="{{ $radius }}"
46
-
fill="none"
47
-
stroke="{{ $color }}"
48
-
stroke-width="2"
49
-
stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}"
50
-
stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}"
51
-
/>
52
-
{{ $offset = addf64 $offset $length }}
53
-
{{ end }}
54
-
</svg>
55
-
<span>{{$success}}/{{ $total }}</span>
56
-
</div>
57
-
{{ end }}
58
-
</div>
59
-
{{ end }}
60
-
61
-
{{ define "tooltip" }}
62
-
<div class="absolute z-[9999] hidden group-hover:block bg-white dark:bg-gray-900 text-sm text-black dark:text-white rounded-md shadow p-2 w-80 top-full mt-2">
63
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
64
-
{{ range $name, $all := .Statuses }}
65
-
<div class="flex items-center justify-between p-1">
66
-
{{ $lastStatus := $all.Latest }}
67
-
{{ $kind := $lastStatus.Status.String }}
68
-
69
-
{{ $icon := "dot" }}
70
-
{{ $color := "text-gray-600 dark:text-gray-500" }}
71
-
{{ $text := "Failed" }}
72
-
{{ $time := "" }}
73
-
74
-
{{ if eq $kind "pending" }}
75
-
{{ $icon = "circle-dashed" }}
76
-
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
77
-
{{ $text = "Queued" }}
78
-
{{ $time = timeFmt $lastStatus.Created }}
79
-
{{ else if eq $kind "running" }}
80
-
{{ $icon = "circle-dashed" }}
81
-
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
82
-
{{ $text = "Running" }}
83
-
{{ $time = timeFmt $lastStatus.Created }}
84
-
{{ else if eq $kind "success" }}
85
-
{{ $icon = "check" }}
86
-
{{ $color = "text-green-600 dark:text-green-500" }}
87
-
{{ $text = "Success" }}
88
-
{{ with $all.TimeTaken }}
89
-
{{ $time = durationFmt . }}
90
-
{{ end }}
91
-
{{ else if eq $kind "cancelled" }}
92
-
{{ $icon = "circle-slash" }}
93
-
{{ $color = "text-gray-600 dark:text-gray-500" }}
94
-
{{ $text = "Cancelled" }}
95
-
{{ with $all.TimeTaken }}
96
-
{{ $time = durationFmt . }}
97
-
{{ end }}
98
-
{{ else }}
99
-
{{ $icon = "x" }}
100
-
{{ $color = "text-red-600 dark:text-red-500" }}
101
-
{{ $text = "Failed" }}
102
-
{{ with $all.TimeTaken }}
103
-
{{ $time = durationFmt . }}
104
-
{{ end }}
105
-
{{ end }}
106
-
107
-
<div id="left" class="flex items-center gap-2 flex-shrink-0">
108
-
{{ i $icon "size-4" $color }}
109
-
{{ $name }}
110
-
</div>
111
-
<div id="right" class="flex items-center gap-2 flex-shrink-0">
112
-
<span class="font-bold">{{ $text }}</span>
113
-
<time class="text-gray-400 dark:text-gray-600">{{ $time }}</time>
114
-
</div>
115
-
</div>
116
-
{{ end }}
117
-
</div>
118
-
</div>
119
-
{{ end }}
+1
-1
appview/pages/templates/repo/index.html
+1
-1
appview/pages/templates/repo/index.html
···
275
275
{{ $pipeline := index $.Pipelines .Hash.String }}
276
276
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
277
277
<div class="inline-block px-1 select-none after:content-['·']"></div>
278
-
{{ template "repo/fragments/pipelineStatusSymbol" $pipeline }}
278
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" $pipeline }}
279
279
{{ end }}
280
280
</div>
281
281
</div>
+2
-2
appview/pages/templates/repo/log.html
+2
-2
appview/pages/templates/repo/log.html
···
84
84
<!-- ci status -->
85
85
{{ $pipeline := index $.Pipelines .Hash.String }}
86
86
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
87
-
{{ template "repo/fragments/pipelineStatusSymbol" $pipeline }}
87
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" $pipeline }}
88
88
{{ end }}
89
89
</td>
90
90
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td>
···
170
170
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
171
171
<div class="inline-block px-1 select-none after:content-['·']"></div>
172
172
<span class="text-sm">
173
-
{{ template "repo/fragments/pipelineStatusSymbol" $pipeline }}
173
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" $pipeline }}
174
174
</span>
175
175
{{ end }}
176
176
</div>
+53
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
+53
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
···
1
+
{{ define "repo/pipelines/fragments/pipelineSymbol" }}
2
+
<div class="cursor-pointer">
3
+
{{ $c := .Counts }}
4
+
{{ $statuses := .Statuses }}
5
+
{{ $total := len $statuses }}
6
+
{{ $success := index $c "success" }}
7
+
{{ $allPass := eq $success $total }}
8
+
9
+
{{ if $allPass }}
10
+
<div class="flex gap-1 items-center">
11
+
{{ i "check" "size-4 text-green-600 dark:text-green-400 " }}
12
+
<span>{{ $total }}/{{ $total }}</span>
13
+
</div>
14
+
{{ else }}
15
+
{{ $radius := f64 8 }}
16
+
{{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }}
17
+
{{ $offset := 0.0 }}
18
+
<div class="flex gap-1 items-center">
19
+
<svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20">
20
+
<circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/>
21
+
22
+
{{ range $kind, $count := $c }}
23
+
{{ $color := "" }}
24
+
{{ if or (eq $kind "pending") (eq $kind "running") }}
25
+
{{ $color = "#eab308" }}
26
+
{{ else if eq $kind "success" }}
27
+
{{ $color = "#10b981" }}
28
+
{{ else if eq $kind "cancelled" }}
29
+
{{ $color = "#6b7280" }}
30
+
{{ else }}
31
+
{{ $color = "#ef4444" }}
32
+
{{ end }}
33
+
34
+
{{ $percent := divf64 (f64 $count) (f64 $total) }}
35
+
{{ $length := mulf64 $percent $circumference }}
36
+
37
+
<circle
38
+
cx="10" cy="10" r="{{ $radius }}"
39
+
fill="none"
40
+
stroke="{{ $color }}"
41
+
stroke-width="2"
42
+
stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}"
43
+
stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}"
44
+
/>
45
+
{{ $offset = addf64 $offset $length }}
46
+
{{ end }}
47
+
</svg>
48
+
<span>{{ $success }}/{{ $total }}</span>
49
+
</div>
50
+
{{ end }}
51
+
</div>
52
+
{{ end }}
53
+
+8
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
+8
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
+30
appview/pages/templates/repo/pipelines/fragments/tooltip.html
+30
appview/pages/templates/repo/pipelines/fragments/tooltip.html
···
1
+
{{ define "repo/pipelines/fragments/tooltip" }}
2
+
<div class="absolute z-[9999] hidden group-hover:block bg-white dark:bg-gray-900 text-black dark:text-white rounded-md shadow w-80 top-full mt-2">
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
4
+
{{ range $name, $all := .Statuses }}
5
+
<div class="flex items-center justify-between p-2">
6
+
{{ $lastStatus := $all.Latest }}
7
+
{{ $kind := $lastStatus.Status.String }}
8
+
9
+
{{ $t := .TimeTaken }}
10
+
{{ $time := "" }}
11
+
{{ if $t }}
12
+
{{ $time = durationFmt $t }}
13
+
{{ else }}
14
+
{{ $time = printf "%s ago" (shortTimeFmt $.Created) }}
15
+
{{ end }}
16
+
17
+
<div id="left" class="flex items-center gap-2 flex-shrink-0">
18
+
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
19
+
{{ $name }}
20
+
</div>
21
+
<div id="right" class="flex items-center gap-2 flex-shrink-0">
22
+
<span class="font-bold">{{ $kind }}</span>
23
+
<time>{{ $time }}</time>
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
30
+
+26
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
+26
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
···
1
+
{{ define "repo/pipelines/fragments/workflowSymbol" }}
2
+
{{ $lastStatus := .Latest }}
3
+
{{ $kind := $lastStatus.Status.String }}
4
+
5
+
{{ $icon := "dot" }}
6
+
{{ $color := "text-gray-600 dark:text-gray-500" }}
7
+
8
+
{{ if eq $kind "pending" }}
9
+
{{ $icon = "circle-dashed" }}
10
+
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
11
+
{{ else if eq $kind "running" }}
12
+
{{ $icon = "circle-dashed" }}
13
+
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
14
+
{{ else if eq $kind "success" }}
15
+
{{ $icon = "check" }}
16
+
{{ $color = "text-green-600 dark:text-green-500" }}
17
+
{{ else if eq $kind "cancelled" }}
18
+
{{ $icon = "circle-slash" }}
19
+
{{ $color = "text-gray-600 dark:text-gray-500" }}
20
+
{{ else }}
21
+
{{ $icon = "x" }}
22
+
{{ $color = "text-red-600 dark:text-red-500" }}
23
+
{{ end }}
24
+
25
+
{{ i $icon "size-4" $color }}
26
+
{{ end }}
+93
appview/pages/templates/repo/pipelines/pipelines.html
+93
appview/pages/templates/repo/pipelines/pipelines.html
···
1
+
{{ define "title" }}pipelines · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $title := "pipelines"}}
5
+
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
6
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
+
{{ end }}
8
+
9
+
{{ define "repoContent" }}
10
+
<div class="flex justify-between items-center gap-4">
11
+
<div class="flex gap-4">
12
+
</div>
13
+
14
+
</div>
15
+
<div class="error" id="issues"></div>
16
+
{{ end }}
17
+
18
+
{{ define "repoAfter" }}
19
+
<section
20
+
class="w-full flex flex-col gap-2 mt-2"
21
+
>
22
+
{{ range .Pipelines }}
23
+
{{ block "pipeline" (list $ .) }} {{ end }}
24
+
{{ else }}
25
+
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
26
+
No pipelines run for this repository.
27
+
</p>
28
+
{{ end }}
29
+
</section>
30
+
{{ end }}
31
+
32
+
{{ define "pipeline" }}
33
+
{{ $root := index . 0 }}
34
+
{{ $p := index . 1 }}
35
+
<div class="py-4 px-6 bg-white dark:bg-gray-800 dark:text-white">
36
+
{{ block "pipelineHeader" $ }} {{ end }}
37
+
</div>
38
+
{{ end }}
39
+
40
+
{{ define "pipelineHeader" }}
41
+
{{ $root := index . 0 }}
42
+
{{ $p := index . 1 }}
43
+
{{ with $p }}
44
+
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 items-center w-full">
45
+
<div class="col-span-1 md:col-span-5 flex items-center gap-4">
46
+
{{ $target := .Trigger.TargetRef }}
47
+
{{ $workflows := .Workflows }}
48
+
{{ if .Trigger.IsPush }}
49
+
<a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}" class="block">
50
+
<span class="font-bold">{{ $target }}</span>
51
+
<span>push</span>
52
+
</a>
53
+
<span class="hidden md:inline-flex gap-2 items-center font-mono text-sm">
54
+
{{ $old := deref .Trigger.PushOldSha }}
55
+
{{ $new := deref .Trigger.PushNewSha }}
56
+
57
+
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $new }}">{{ slice $new 0 8 }}</a>
58
+
{{ i "arrow-left" "size-4" }}
59
+
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $old }}">{{ slice $old 0 8 }}</a>
60
+
</span>
61
+
{{ else if .Trigger.IsPullRequest }}
62
+
<span>
63
+
pull request
64
+
<span class="inline-flex gap-2 items-center">
65
+
{{ $target }}
66
+
{{ i "arrow-left" "size-4" }}
67
+
{{ .Trigger.PRSourceBranch }}
68
+
</span>
69
+
</span>
70
+
{{ end }}
71
+
</div>
72
+
73
+
<div class="col-span-1 pl-4">
74
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" . }}
75
+
</div>
76
+
77
+
<div class="col-span-1 text-right">
78
+
<time title="{{ .Created | longTimeFmt }}">
79
+
{{ .Created | shortTimeFmt }} ago
80
+
</time>
81
+
</div>
82
+
83
+
{{ $t := .TimeTaken }}
84
+
<div class="col-span-1 text-right">
85
+
{{ if $t }}
86
+
<time title="{{ $t }}">{{ $t | durationFmt }}</time>
87
+
{{ else }}
88
+
<time>--</time>
89
+
{{ end }}
90
+
</div>
91
+
</div>
92
+
{{ end }}
93
+
{{ end }}
+30
appview/pipelines/pipelines.go
+30
appview/pipelines/pipelines.go
···
56
56
Logger: logger,
57
57
}
58
58
}
59
+
60
+
func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) {
61
+
user := p.oauth.GetUser(r)
62
+
l := p.Logger.With("handler", "Index")
63
+
64
+
f, err := p.repoResolver.Resolve(r)
65
+
if err != nil {
66
+
l.Error("failed to get repo and knot", "err", err)
67
+
return
68
+
}
69
+
70
+
repoInfo := f.RepoInfo(user)
71
+
72
+
ps, err := db.GetPipelineStatuses(
73
+
p.db,
74
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
75
+
db.FilterEq("repo_name", repoInfo.Name),
76
+
db.FilterEq("knot", repoInfo.Knot),
77
+
)
78
+
if err != nil {
79
+
l.Error("failed to query db", "err", err)
80
+
return
81
+
}
82
+
83
+
p.pages.Pipelines(w, pages.PipelinesParams{
84
+
LoggedInUser: user,
85
+
RepoInfo: repoInfo,
86
+
Pipelines: ps,
87
+
})
88
+
}