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.Attempt > 1 {
44 <span class="pill">attempt { itoa(int(r.Attempt)) }</span>
45 }
46 </td>
47 <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td>
48 <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td>
49 <td class="c-who dim">{ r.Actor }</td>
50 <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td>
51 <td class="c-when dim">
52 if r.WhenAt != "" {
53 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
54 }
55 </td>
56 </tr>
57}
58
59templ RunDetail(l Layout, p RunPage) {
60 @Shell(l, runRail(p)) {
61 @LiveSlot("run-notice")
62 <div id="run-body">
63 @RunBody(p)
64 </div>
65 @RunSummaries(p.Summaries, p.SummariesMore)
66 }
67}
68
69// runBodyFragment is the body of the run page as a live fragment.
70templ runBodyFragment(p RunPage) {
71 <div id="run-body">
72 @RunBody(p)
73 </div>
74}
75
76// RunBody is the jobs of a run, replaced by the live updates.
77templ RunBody(p RunPage) {
78 <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2>
79 if p.Error != "" {
80 <pre class="run-err">{ p.Error }</pre>
81 }
82 if len(p.Annotations) > 0 {
83 <section class="annots" aria-labelledby="run-annots">
84 <h3 id="run-annots">Annotations <span class="count">{ itoa(len(p.Annotations)) }</span></h3>
85 @AnnotationList(p.Annotations, p.AnnotationsMore, false)
86 </section>
87 }
88 <ul class="jobs">
89 for _, j := range p.Jobs {
90 <li id={ "job-" + j.ID }>
91 <a class="job-h" href={ templ.SafeURL(j.Href) }>
92 @CheckIcon(j.Kind, j.State)
93 <span class="j-name">{ j.Name }</span>
94 <span class="dim">{ j.State }{ " " }{ j.Duration }</span>
95 </a>
96 if j.Runner != "" {
97 <span class="mute">on { runnerText(j) }</span>
98 }
99 if len(j.Needs) > 0 {
100 <span class="mute">needs { joinWords(j.Needs) }</span>
101 }
102 if j.Error != "" {
103 <p class="job-err">{ j.Error }</p>
104 }
105 if len(j.Steps) > 0 {
106 <ol class="steps">
107 for _, s := range j.Steps {
108 <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li>
109 }
110 </ol>
111 }
112 </li>
113 }
114 </ul>
115}
116
117// AnnotationList is a list of annotations: the level as a mark with its word,
118// the job and the step, the title and the message, and file:line. Everything
119// comes from the workflow and is text. Without grouped, each item names its job (the
120// run page); grouped lists the annotations by step instead (the job page).
121templ AnnotationList(anns []AnnotationView, more bool, grouped bool) {
122 <ul class="annot-list">
123 for _, a := range anns {
124 <li class={ "annot", "annot-" + a.Kind }>
125 if grouped && a.StepHead {
126 <p class="annot-step">
127 if a.StepHref != "" {
128 <a href={ templ.SafeURL(a.StepHref) }>{ a.Step }</a>
129 } else {
130 { a.Step }
131 }
132 </p>
133 }
134 <span class={ "an", "an-" + a.Kind } role="img" aria-label={ a.Level } title={ a.Level }></span>
135 <div class="annot-body">
136 <p class="annot-head">
137 <span class="annot-level">{ a.Level }</span>
138 if !grouped {
139 if a.JobHref != "" {
140 <a href={ templ.SafeURL(a.JobHref) }>{ a.Job }</a>
141 } else {
142 <span>{ a.Job }</span>
143 }
144 <span class="mute">{ a.Step }</span>
145 }
146 if a.Title != "" {
147 <strong>{ a.Title }</strong>
148 }
149 </p>
150 <p class="annot-msg">{ a.Message }</p>
151 if a.Where != "" {
152 <p class="annot-where">
153 if a.WhereHref != "" {
154 <a href={ templ.SafeURL(a.WhereHref) }>{ a.Where }</a>
155 } else {
156 <span>{ a.Where }</span>
157 }
158 </p>
159 }
160 </div>
161 </li>
162 }
163 </ul>
164 if more {
165 <p class="mute">more annotations exist than are shown.</p>
166 }
167}
168
169// JobAnnotations is the annotations of the job page, replaced by the live
170// updates; the element exists even when there is none, so that the first one
171// has something to replace.
172templ JobAnnotations(anns []AnnotationView, more bool) {
173 <section id="job-annotations" class="annots" aria-label="Annotations">
174 if len(anns) > 0 {
175 <h3>Annotations <span class="count">{ itoa(len(anns)) }</span></h3>
176 @AnnotationList(anns, more, true)
177 }
178 </section>
179}
180
181// RunSummaries is the job summaries of a run, one block per step that wrote
182// one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs
183// filtered, images shown as their alt text.
184templ RunSummaries(sums []JobSummaries, more bool) {
185 for _, j := range sums {
186 <section class="summaries" aria-label={ "Summary of " + j.Job }>
187 <h3>
188 if j.JobHref != "" {
189 <a href={ templ.SafeURL(j.JobHref) }>{ j.Job }</a>
190 } else {
191 { j.Job }
192 }
193 <span class="mute">summary</span>
194 </h3>
195 for _, s := range j.Steps {
196 <article class="prose summary" aria-label={ s.Step } hx-ignore>
197 <h4 class="summary-step">{ s.Step }</h4>
198 if s.Note != "" {
199 <p class="dim">{ s.Note }</p>
200 } else {
201 @templ.Raw(s.HTML)
202 }
203 </article>
204 }
205 if j.More {
206 <p class="mute">the job has more summaries than the first 20, which are shown.</p>
207 }
208 </section>
209 }
210 if more {
211 <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p>
212 }
213}
214
215templ runRail(p RunPage) {
216 <section aria-labelledby="r-run">
217 <h2 id="r-run">Run</h2>
218 <dl class="kv">
219 <dt>result</dt>
220 <dd>{ p.State }</dd>
221 <dt>commit</dt>
222 <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd>
223 <dt>ref</dt>
224 <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd>
225 <dt>actor</dt>
226 <dd>{ p.Actor }</dd>
227 <dt>attempt</dt>
228 <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd>
229 </dl>
230 if len(p.Attempts) > 0 {
231 <ul class="attempts">
232 for _, a := range p.Attempts {
233 <li>
234 if a.Current {
235 <span aria-current="page">{ a.Label }</span>
236 } else {
237 <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a>
238 }
239 </li>
240 }
241 </ul>
242 }
243 </section>
244 if p.CancelAction != "" || p.RerunAction != "" {
245 <section aria-labelledby="r-act" class="decide">
246 <h2 id="r-act">Actions</h2>
247 <div class="d-btns">
248 if p.CancelAction != "" {
249 <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form>
250 }
251 if p.RerunAction != "" {
252 <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form>
253 }
254 if p.CanRerunFailed {
255 <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form>
256 }
257 </div>
258 </section>
259 }
260}
261
262// JobHead is the title of the job page with its state and its error, replaced
263// by the live updates. total is the number of lines the log holds.
264templ JobHead(j JobRow, total int64) {
265 <div id="job-head" data-total={ itoa64(total) }>
266 <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2>
267 if j.Error != "" {
268 <p class="job-err">{ j.Error }</p>
269 }
270 </div>
271}
272
273// JobSteps is the steps of the job in the rail, replaced by the live updates.
274templ JobSteps(steps []StepView) {
275 <ol class="steps" id="job-steps">
276 for _, s := range steps {
277 <li>
278 @CheckIcon(s.Kind, s.State)
279 if s.LogEnd > s.LogStart {
280 <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a>
281 } else {
282 <span>{ s.Name }</span>
283 }
284 <span class="dim">{ s.Duration }</span>
285 </li>
286 }
287 </ol>
288}
289
290templ Job(l Layout, p JobPage) {
291 @Shell(l, jobRail(p)) {
292 @LiveSlot("job-notice")
293 @JobHead(p.JobRow, p.Total)
294 @JobAnnotations(p.Annotations, p.AnnotationsMore)
295 <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))) }>
296 if p.Note != "" {
297 <p class="dim">{ p.Note }</p>
298 }
299 if p.Prev != "" {
300 <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p>
301 }
302 <ol class="log" start={ itoa64(p.From + 1) }>
303 for _, ln := range p.Lines {
304 @LogLine(ln)
305 }
306 </ol>
307 if p.Next != "" {
308 <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p>
309 }
310 </div>
311 }
312}
313
314// LogLine is a line of the log. Group and error markers of the runner show as
315// headings and errors, never as raw text.
316templ LogLine(ln LogLineView) {
317 <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>
318}
319
320templ jobRail(p JobPage) {
321 <section aria-labelledby="r-job">
322 <h2 id="r-job">Steps</h2>
323 @JobSteps(p.Steps)
324 </section>
325 <section aria-labelledby="r-jobf">
326 <h2 id="r-jobf">Job</h2>
327 <dl class="kv">
328 <dt>run</dt>
329 <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd>
330 if p.Runner != "" {
331 <dt>runner</dt>
332 <dd>{ p.Runner }</dd>
333 }
334 if p.Isolation != "" {
335 <dt>isolation</dt>
336 <dd>{ p.Isolation }</dd>
337 }
338 <dt>lines</dt>
339 <dd>{ itoa64(p.Total) }</dd>
340 </dl>
341 <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p>
342 </section>
343}
344
345// CommitChecks is the combined state of the checks of a commit, as a mark
346// with a count, linking to the page that explains it.
347templ CommitChecks(c ChecksSummary) {
348 if c.Total > 0 {
349 <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }>
350 @CheckIcon(c.Kind, c.State)
351 <span class="mute">{ c.Label }</span>
352 </a>
353 }
354}
355
356// MRChecks is the checks section of the rail of a merge request.
357templ MRChecks(c ChecksSection) {
358 <section aria-labelledby="r-checks" id="mr-checks">
359 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
360 if len(c.Rows) == 0 {
361 <p class="dim">no checks</p>
362 } else {
363 <ul class="checks">
364 for _, r := range c.Rows {
365 <li>
366 @CheckIcon(r.Kind, r.State)
367 if r.Href != "" {
368 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
369 } else {
370 <span>{ r.Name }</span>
371 }
372 <span class="dim">{ r.State }</span>
373 if r.Waiting {
374 <span class="mute">waiting for a runner</span>
375 }
376 </li>
377 }
378 </ul>
379 }
380 if c.Note != "" {
381 <p class="mute">{ c.Note }</p>
382 }
383 </section>
384}