1package web
2
3// The CI pages: the runs of a repository, a run with its jobs, a job with its
4// steps and its log (actions.go). A state is always a word as well as a
5// symbol and a colour.
6
7// CheckIcon is the symbol of a state: a mark in the colour of its kind, with
8// the state as its text for a screen reader.
9templ CheckIcon(kind, state string) {
10 <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span>
11}
12
13templ Actions(l Layout, p ActionsPage) {
14 @Shell(l, nil) {
15 if len(p.Runs) == 0 {
16 <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>
17 } else {
18 <table class="list runs" aria-label="Runs">
19 <thead>
20 <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr>
21 </thead>
22 <tbody id="run-rows" data-rows>
23 for _, r := range p.Runs {
24 @RunListRow(r)
25 }
26 </tbody>
27 </table>
28 }
29 if p.More != "" {
30 <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p>
31 }
32 }
33}
34
35// RunListRow is a row of the list of runs; its id is the target of the live
36// updates.
37templ RunListRow(r RunRow) {
38 <tr id={ "run-" + r.ID }>
39 <td class="c-st">@CheckIcon(r.Kind, r.State)</td>
40 <td class="c-name">
41 <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a>
42 <span class="mute">{ r.Number }</span>
43 if r.Event != "" {
44 <span class="pill">{ r.Event }</span>
45 }
46 if r.Attempt > 1 {
47 <span class="pill">attempt { itoa(int(r.Attempt)) }</span>
48 }
49 </td>
50 <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td>
51 <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td>
52 <td class="c-who dim">{ r.Actor }</td>
53 <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td>
54 <td class="c-when dim">
55 if r.WhenAt != "" {
56 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
57 }
58 </td>
59 </tr>
60}
61
62// RecentActionsBlock is the "Recent actions" section of the summary rail; its
63// id is the target of the live updates. Without a run it is hidden.
64templ RecentActionsBlock(a RecentActions) {
65 <section id="recent-actions" aria-labelledby="r-actions" hidden?={ len(a.Runs) == 0 }>
66 <h2 id="r-actions">Recent actions <a href={ templ.SafeURL(a.AllHref) }>View all</a></h2>
67 <ul class="rruns">
68 for _, r := range a.Runs {
69 <li>
70 @CheckIcon(r.Kind, r.State)
71 <a class="rr-name" href={ templ.SafeURL(r.Href) } title={ r.Workflow + " " + r.Number + " · " + r.State }>{ r.Workflow }</a>
72 <span class="mute">{ r.Number }</span>
73 <a class="rr-ref" href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a>
74 if r.WhenAt != "" {
75 <time class="dim" datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
76 }
77 </li>
78 }
79 </ul>
80 </section>
81}
82
83templ RunDetail(l Layout, p RunPage) {
84 @Shell(l, runRail(p)) {
85 @LiveSlot("run-notice")
86 <div id="run-body">
87 @RunBody(p)
88 </div>
89 if p.Artifacts != nil {
90 @RunArtifacts(*p.Artifacts)
91 }
92 @RunSummaries(p.Summaries, p.SummariesMore)
93 }
94}
95
96// runBodyFragment is the body of the run page as a live fragment.
97templ runBodyFragment(p RunPage) {
98 <div id="run-body">
99 @RunBody(p)
100 </div>
101}
102
103// RunBody is the jobs of a run, replaced by the live updates.
104templ RunBody(p RunPage) {
105 <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2>
106 if p.Error != "" {
107 <pre class="run-err">{ p.Error }</pre>
108 }
109 if len(p.Annotations) > 0 {
110 <section class="annots" aria-labelledby="run-annots">
111 <h3 id="run-annots">Annotations <span class="count">{ itoa(len(p.Annotations)) }</span></h3>
112 @AnnotationList(p.Annotations, p.AnnotationsMore, false)
113 </section>
114 }
115 <ul class="jobs">
116 for _, j := range p.Jobs {
117 <li id={ "job-" + j.ID }>
118 <a class="job-h" href={ templ.SafeURL(j.Href) }>
119 @CheckIcon(j.Kind, j.State)
120 <span class="j-name">{ j.Name }</span>
121 <span class="dim">{ j.State }{ " " }{ j.Duration }</span>
122 </a>
123 if j.Runner != "" {
124 <span class="mute">on { runnerText(j) }</span>
125 }
126 if len(j.Needs) > 0 {
127 <span class="mute">needs { joinWords(j.Needs) }</span>
128 }
129 if j.Error != "" {
130 <p class="job-err">{ j.Error }</p>
131 }
132 if len(j.Steps) > 0 {
133 <ol class="steps">
134 for _, s := range j.Steps {
135 <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li>
136 }
137 </ol>
138 }
139 </li>
140 }
141 </ul>
142}
143
144// AnnotationList is a list of annotations: the level as a mark with its word,
145// the job and the step, the title and the message, and file:line. Everything
146// comes from the workflow and is text. Without grouped, each item names its job (the
147// run page); grouped lists the annotations by step instead (the job page).
148templ AnnotationList(anns []AnnotationView, more bool, grouped bool) {
149 <ul class="annot-list">
150 for _, a := range anns {
151 <li class={ "annot", "annot-" + a.Kind }>
152 if grouped && a.StepHead {
153 <p class="annot-step">
154 if a.StepHref != "" {
155 <a href={ templ.SafeURL(a.StepHref) }>{ a.Step }</a>
156 } else {
157 { a.Step }
158 }
159 </p>
160 }
161 <span class={ "an", "an-" + a.Kind } role="img" aria-label={ a.Level } title={ a.Level }></span>
162 <div class="annot-body">
163 <p class="annot-head">
164 <span class="annot-level">{ a.Level }</span>
165 if !grouped {
166 if a.JobHref != "" {
167 <a href={ templ.SafeURL(a.JobHref) }>{ a.Job }</a>
168 } else {
169 <span>{ a.Job }</span>
170 }
171 <span class="mute">{ a.Step }</span>
172 }
173 if a.Title != "" {
174 <strong>{ a.Title }</strong>
175 }
176 </p>
177 <p class="annot-msg">{ a.Message }</p>
178 if a.Where != "" {
179 <p class="annot-where">
180 if a.WhereHref != "" {
181 <a href={ templ.SafeURL(a.WhereHref) }>{ a.Where }</a>
182 } else {
183 <span>{ a.Where }</span>
184 }
185 </p>
186 }
187 </div>
188 </li>
189 }
190 </ul>
191 if more {
192 <p class="mute">more annotations exist than are shown.</p>
193 }
194}
195
196// JobAnnotations is the annotations of the job page, replaced by the live
197// updates; the element exists even when there is none, so that the first one
198// has something to replace.
199templ JobAnnotations(anns []AnnotationView, more bool) {
200 <section id="job-annotations" class="annots" aria-label="Annotations">
201 if len(anns) > 0 {
202 <h3>Annotations <span class="count">{ itoa(len(anns)) }</span></h3>
203 @AnnotationList(anns, more, true)
204 }
205 </section>
206}
207
208// RunArtifacts is the artifacts of a run, with the space the artifacts of
209// the repository use. Names come from the workflow and are text.
210templ RunArtifacts(a ArtifactsView) {
211 <section class="artifacts" aria-labelledby="run-artifacts">
212 <h3 id="run-artifacts">Artifacts <span class="count">{ itoa(len(a.Rows)) }</span></h3>
213 <table class="list" aria-label="Artifacts">
214 <thead>
215 <tr><th class="c-name">name</th><th class="c-sub">size</th><th class="c-who">job</th><th class="c-when">expires</th><th></th></tr>
216 </thead>
217 <tbody>
218 for _, r := range a.Rows {
219 <tr>
220 <td class="c-name"><a href={ templ.SafeURL(r.Href) } download>{ r.Name }</a></td>
221 <td class="c-sub dim">{ r.Size }</td>
222 <td class="c-who">
223 if r.JobHref != "" {
224 <a href={ templ.SafeURL(r.JobHref) }>{ r.Job }</a>
225 } else {
226 <span class="dim">{ r.Job }</span>
227 }
228 </td>
229 <td class="c-when dim">
230 if r.ExpiresAt != "" {
231 <time datetime={ r.ExpiresAt }>{ r.Expires }</time>
232 } else {
233 { r.Expires }
234 }
235 </td>
236 <td>
237 if r.DeleteAction != "" {
238 <form method="post" action={ templ.SafeURL(r.DeleteAction) }><button type="submit" class="btn" aria-label={ "Delete " + r.Name }>Delete</button></form>
239 }
240 </td>
241 </tr>
242 }
243 </tbody>
244 </table>
245 <p class="mute">{ a.Usage }</p>
246 </section>
247}
248
249// RunSummaries is the job summaries of a run, one block per step that wrote
250// one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs
251// filtered, images shown as their alt text.
252templ RunSummaries(sums []JobSummaries, more bool) {
253 for _, j := range sums {
254 <section class="summaries" aria-label={ "Summary of " + j.Job }>
255 <h3>
256 if j.JobHref != "" {
257 <a href={ templ.SafeURL(j.JobHref) }>{ j.Job }</a>
258 } else {
259 { j.Job }
260 }
261 <span class="mute">summary</span>
262 </h3>
263 for _, s := range j.Steps {
264 <article class="prose summary" aria-label={ s.Step } hx-ignore>
265 <h4 class="summary-step">{ s.Step }</h4>
266 if s.Note != "" {
267 <p class="dim">{ s.Note }</p>
268 } else {
269 @templ.Raw(s.HTML)
270 }
271 </article>
272 }
273 if j.More {
274 <p class="mute">the job has more summaries than the first 20, which are shown.</p>
275 }
276 </section>
277 }
278 if more {
279 <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p>
280 }
281}
282
283templ runRail(p RunPage) {
284 <section aria-labelledby="r-run">
285 <h2 id="r-run">Run</h2>
286 <dl class="kv">
287 <dt>result</dt>
288 <dd>{ p.State }</dd>
289 <dt>event</dt>
290 <dd>{ p.Event }</dd>
291 <dt>commit</dt>
292 <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd>
293 <dt>ref</dt>
294 <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd>
295 <dt>actor</dt>
296 <dd>{ p.Actor }</dd>
297 <dt>attempt</dt>
298 <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd>
299 </dl>
300 if len(p.Attempts) > 0 {
301 <ul class="attempts">
302 for _, a := range p.Attempts {
303 <li>
304 if a.Current {
305 <span aria-current="page">{ a.Label }</span>
306 } else {
307 <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a>
308 }
309 </li>
310 }
311 </ul>
312 }
313 </section>
314 if p.CancelAction != "" || p.RerunAction != "" {
315 <section aria-labelledby="r-act" class="decide">
316 <h2 id="r-act">Actions</h2>
317 <div class="d-btns">
318 if p.CancelAction != "" {
319 <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form>
320 }
321 if p.RerunAction != "" {
322 <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form>
323 }
324 if p.CanRerunFailed {
325 <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form>
326 }
327 </div>
328 </section>
329 }
330}
331
332// JobHead is the title of the job page with its state and its error, replaced
333// by the live updates. total is the number of lines the log holds.
334templ JobHead(j JobRow, total int64) {
335 <div id="job-head" data-total={ itoa64(total) }>
336 <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2>
337 if j.Error != "" {
338 <p class="job-err">{ j.Error }</p>
339 }
340 </div>
341}
342
343// JobSteps is the steps of the job in the rail, replaced by the live updates.
344templ JobSteps(steps []StepView) {
345 <ol class="steps" id="job-steps">
346 for _, s := range steps {
347 <li>
348 @CheckIcon(s.Kind, s.State)
349 if s.LogEnd > s.LogStart {
350 <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a>
351 } else {
352 <span>{ s.Name }</span>
353 }
354 <span class="dim">{ s.Duration }</span>
355 </li>
356 }
357 </ol>
358}
359
360templ Job(l Layout, p JobPage) {
361 @Shell(l, jobRail(p)) {
362 @LiveSlot("job-notice")
363 @JobHead(p.JobRow, p.Total)
364 @JobAnnotations(p.Annotations, p.AnnotationsMore)
365 <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }>
366 if p.Note != "" {
367 <p class="dim">{ p.Note }</p>
368 }
369 if p.Prev != "" {
370 <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p>
371 }
372 <ol class="log" start={ itoa64(p.From + 1) }>
373 for _, ln := range p.Lines {
374 @LogLine(ln)
375 }
376 </ol>
377 if p.Next != "" {
378 <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p>
379 }
380 </div>
381 }
382}
383
384// LogLine is a line of the log. Group and error markers of the runner show as
385// headings and errors, never as raw text.
386templ LogLine(ln LogLineView) {
387 <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) } data-cont?={ ln.Continued }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
388}
389
390templ jobRail(p JobPage) {
391 <section aria-labelledby="r-job">
392 <h2 id="r-job">Steps</h2>
393 @JobSteps(p.Steps)
394 </section>
395 <section aria-labelledby="r-jobf">
396 <h2 id="r-jobf">Job</h2>
397 <dl class="kv">
398 <dt>run</dt>
399 <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd>
400 if p.Runner != "" {
401 <dt>runner</dt>
402 <dd>{ p.Runner }</dd>
403 }
404 if p.Isolation != "" {
405 <dt>isolation</dt>
406 <dd>{ p.Isolation }</dd>
407 }
408 <dt>lines</dt>
409 <dd>{ itoa64(p.Total) }</dd>
410 </dl>
411 <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p>
412 </section>
413}
414
415// CommitChecks is the combined state of the checks of a commit, as a mark
416// with a count, linking to the page that explains it.
417templ CommitChecks(c ChecksSummary) {
418 if c.Total > 0 {
419 <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }>
420 @CheckIcon(c.Kind, c.State)
421 <span class="mute">{ c.Label }</span>
422 </a>
423 }
424}
425
426// MRChecks is the checks section of the rail of a merge request.
427templ MRChecks(c ChecksSection) {
428 <section aria-labelledby="r-checks" id="mr-checks">
429 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
430 if len(c.Rows) == 0 {
431 <p class="dim">no checks</p>
432 } else {
433 <ul class="checks">
434 for _, r := range c.Rows {
435 <li>
436 @CheckIcon(r.Kind, r.State)
437 if r.Href != "" {
438 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
439 } else {
440 <span>{ r.Name }</span>
441 }
442 <span class="dim">{ r.State }</span>
443 if r.Waiting {
444 <span class="mute">waiting for a runner</span>
445 }
446 </li>
447 }
448 </ul>
449 }
450 if c.Note != "" {
451 <p class="mute">{ c.Note }</p>
452 }
453 </section>
454}