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