From 84a767bfb7c86e92fa1b892e63596e5ca09db4fd Mon Sep 17 00:00:00 2001 From: Navin Date: Wed, 29 Mar 2023 15:37:10 +1100 Subject: [PATCH] Logs over websockets (#794) * Route and controller for logs over websocket. * Porting over logs processing to jsoo. * Pin scrolling to the bottom (for logs). * All in on Brr - remove jsoo deps. * Tidy. * Build log line markup using Brr. * Call function to extract repro steps in jsoo. * Apply suggestions from code review Co-authored-by: Tim McGilchrist * Tidy. --------- Co-authored-by: Tim McGilchrist --- .gitignore | 3 + dune-project | 7 +- ocaml-ci-web.opam | 3 + web-ui/controller/git_forge.ml | 48 +- web-ui/jsoo/dune | 7 + web-ui/jsoo/main.ml | 78 +++ web-ui/jsoo/process_chunk.ml | 78 +++ web-ui/jsoo/steps_to_reproduce_build.ml | 15 + web-ui/router.ml | 13 + web-ui/static/css/style.css | 11 + web-ui/static/js/add-repro-steps.js | 28 - web-ui/static/js/dune | 7 + web-ui/static/js/step-page-poll.js | 51 +- web-ui/view/dune | 2 +- web-ui/view/git_forge_intf.ml | 2 +- web-ui/view/step.ml | 730 ++++++++++-------------- web-ui/view/step.mli | 2 +- 17 files changed, 624 insertions(+), 461 deletions(-) create mode 100644 web-ui/jsoo/dune create mode 100644 web-ui/jsoo/main.ml create mode 100644 web-ui/jsoo/process_chunk.ml create mode 100644 web-ui/jsoo/steps_to_reproduce_build.ml delete mode 100644 web-ui/static/js/add-repro-steps.js create mode 100644 web-ui/static/js/dune diff --git a/.gitignore b/.gitignore index ef876ab3..b7d1e63b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ var .vscode /capnp-secrets/ .DS_Store + +/* The file below is generated by jsoo */ +web-ui/static/js/step-logs.js diff --git a/dune-project b/dune-project index 4970aabb..793557ee 100644 --- a/dune-project +++ b/dune-project @@ -124,4 +124,9 @@ (prometheus-app (>= 1.2)) tyxml tailwindcss - (timedesc (>= 0.9.0)))) + (timedesc (>= 0.9.0)) + js_of_ocaml + js_of_ocaml-ppx + (js_of_ocaml (>= 5.1.1)) + (brr (>= 0.0.4)) + )) diff --git a/ocaml-ci-web.opam b/ocaml-ci-web.opam index cb015cbe..037acf63 100644 --- a/ocaml-ci-web.opam +++ b/ocaml-ci-web.opam @@ -21,6 +21,9 @@ depends: [ "tyxml" "tailwindcss" "timedesc" {>= "0.9.0"} + "js_of_ocaml" + "js_of_ocaml-ppx" + "brr" "odoc" {with-doc} ] build: [ diff --git a/web-ui/controller/git_forge.ml b/web-ui/controller/git_forge.ml index 25d479ff..44a1097b 100644 --- a/web-ui/controller/git_forge.ml +++ b/web-ui/controller/git_forge.ml @@ -33,6 +33,15 @@ module type Controller = sig Backend.t -> Dream.server Dream.message Lwt.t + val ws_show_step : + org:string -> + repo:string -> + hash:string -> + variant:string -> + Dream.client Dream.message -> + Backend.t -> + Dream.server Dream.message Lwt.t + val cancel_step : org:string -> repo:string -> @@ -190,10 +199,11 @@ module Make (View : View) : Controller = struct let can_rebuild = status.Current_rpc.Job.can_rebuild in let can_cancel = status.can_cancel in - View.show_step ~org ~repo ~refs ~hash ~variant ~status:step_info.outcome - ~csrf_token ~flash_messages ~timestamps ~build_created_at - ~step_created_at ~step_finished_at ~can_rebuild ~can_cancel - ~job:job_cap chunk + Dream.respond + @@ View.show_step ~org ~repo ~refs ~hash ~variant + ~status:step_info.outcome ~csrf_token ~flash_messages ~timestamps + ~build_created_at ~step_created_at ~step_finished_at ~can_rebuild + ~can_cancel ~job:job_cap chunk let cancel_step ~org ~repo ~hash ~variant request ci = Backend.ci ci >>= fun ci -> @@ -319,4 +329,34 @@ module Make (View : View) : Controller = struct in List.iter (fun (s, m) -> Dream.add_flash_message request s m) flash_messages; Dream.redirect request (Fmt.str "/github/%s/%s/commit/%s" org repo hash) + + let ws_show_step ~org ~repo ~hash ~variant _request ci = + Backend.ci ci >>= fun ci -> + Capability.with_ref (Client.CI.org ci org) @@ fun org_cap -> + Capability.with_ref (Client.Org.repo org_cap repo) @@ fun repo_cap -> + Capability.with_ref (Client.Repo.commit_of_hash repo_cap hash) + @@ fun commit_cap -> + let refs = Client.Commit.refs commit_cap in + let jobs = Client.Commit.jobs commit_cap in + Capability.with_ref (Client.Commit.job_of_variant commit_cap variant) + @@ fun job_cap -> + let status = Current_rpc.Job.status job_cap in + Current_rpc.Job.log job_cap ~start:0L >>!= fun (data, next) -> + (* (these will have resolved by now) *) + refs >>!= fun _refs -> + jobs >>!= fun _jobs -> + status >>!= fun _status -> + Capability.inc_ref job_cap; + + Dream.websocket @@ fun websocket -> + Dream.send websocket data >>= fun () -> + let rec loop next = + Current_rpc.Job.log job_cap ~start:next >>= function + | Ok ("", _) -> Dream.close_websocket websocket + | Ok (data, next) -> Dream.send websocket data >>= fun () -> loop next + | Error (`Capnp ex) -> + Dream.log "Error fetching logs: %a" Capnp_rpc.Error.pp ex; + Dream.close_websocket websocket + in + loop next end diff --git a/web-ui/jsoo/dune b/web-ui/jsoo/dune new file mode 100644 index 00000000..75e06368 --- /dev/null +++ b/web-ui/jsoo/dune @@ -0,0 +1,7 @@ +(executable + (modules process_chunk main steps_to_reproduce_build) + (name main) + (modes js) + (preprocess + (pps js_of_ocaml-ppx)) + (libraries ansi astring brr)) diff --git a/web-ui/jsoo/main.ml b/web-ui/jsoo/main.ml new file mode 100644 index 00000000..ddda95b1 --- /dev/null +++ b/web-ui/jsoo/main.ml @@ -0,0 +1,78 @@ +module WebSocket = Brr_io.Websocket +module Ev = Brr.Ev +module El = Brr.El +module Document = Brr.Document +module Window = Brr.Window + +let regexp_left_paren = Re.Str.regexp_string "(" +let regexp_right_paren = Re.Str.regexp_string ")" +let encoded_left_paren = "%28" +let encoded_right_paren = "%29" + +let encode_parens s : Jstr.t = + let s_lp_encoded = + Jstr.to_string s + |> Re.Str.global_replace regexp_left_paren encoded_left_paren + in + let s_rp_encoded = + Re.Str.global_replace regexp_right_paren encoded_right_paren s_lp_encoded + in + Jstr.of_string s_rp_encoded + +let inject_log_lines first_line_repro_block last_line_repro_block data = + match Document.find_el_by_id Brr.G.document (Jstr.of_string "logs-pre") with + | None -> assert false + | Some scroller -> + El.append_children scroller data; + if !last_line_repro_block <> 0 then + Steps_to_reproduce_build.go !first_line_repro_block + !last_line_repro_block; + () + +let ws_path window = + let location = Brr.Window.location window in + let pathname = Brr.Uri.path location in + let port = Brr.Uri.port location in + let hostname = Brr.Uri.host location in + match port with + | None -> + Jstr.concat ~sep:(Jstr.of_string "/") + [ + Jstr.of_string "ws:/"; + hostname; + encode_parens @@ Jstr.append (Jstr.of_string "ws") pathname; + ] + | Some port -> + Jstr.concat ~sep:(Jstr.of_string "/") + [ + Jstr.of_string "ws:/"; + Jstr.concat ~sep:(Jstr.of_string ":") [ hostname; Jstr.of_int port ]; + encode_parens @@ Jstr.append (Jstr.of_string "ws") pathname; + ] + +(* It looks like parens are not being percent encoded - see https://github.com/dbuenzli/brr/issues/47. + Once this is fixed then something similar to the below should work: + Brr.Uri.with_uri ~scheme:(Jstr.of_string "ws") ~path:(pathname) location *) + +let fetch_logs = + let line_number = ref 0 in + let first_line_repro_block = ref 0 in + let last_line_repro_block = ref 0 in + + let window = Brr.G.window in + (* this will throw an exception if the encoding fails *) + let ws_path = ws_path window in + let socket = WebSocket.create ws_path in + let target = WebSocket.as_target socket in + let (_result : Ev.listener) = + Ev.listen Brr_io.Message.Ev.message + (fun e -> + let data = Brr_io.Message.Ev.data (Ev.as_type e) in + inject_log_lines first_line_repro_block last_line_repro_block + @@ Process_chunk.go line_number first_line_repro_block + last_line_repro_block (Jstr.to_string data)) + target + in + () + +let () = ignore fetch_logs diff --git a/web-ui/jsoo/process_chunk.ml b/web-ui/jsoo/process_chunk.ml new file mode 100644 index 00000000..cd729140 --- /dev/null +++ b/web-ui/jsoo/process_chunk.ml @@ -0,0 +1,78 @@ +module El = Brr.El +module At = Brr.At + +let ansi = Ansi.create () + +let set_inner_html el html = + Jv.set (El.to_jv el) "innerHTML" (Jv.of_string html) + +let collapse_carriage_returns log_line = + let rec last = function + | [] -> raise (Failure "Trying to take log_line from empty list (BUG)") + | [ s ] -> s + | _ :: l -> last l + in + match log_line with + | "" -> "" + | log_line -> Astring.String.cuts ~sep:"\r" log_line |> last + +let to_element ~line_number_id ~log_line ~code_line_class : El.t = + ignore line_number_id; + + let colon_class_str = + "parseInt($el.id.substring(1, $el.id.length)) >= startingLine && \ + parseInt($el.id.substring(1, $el.id.length)) <= endingLine ? 'highlight' \ + : ''" + in + let code = El.code [] in + let () = set_inner_html code log_line in + if code_line_class <> "" then El.set_class (Jstr.v code_line_class) true code; + + let span = El.span [] in + El.set_class (Jstr.v "th") true span; + El.set_at (Jstr.v "data-line-number") (Some (Jstr.v line_number_id)) span; + + let result = El.span ~at:At.[ id (Jstr.v line_number_id) ] [ span; code ] in + El.set_class (Jstr.v "tr") true result; + El.set_at (Jstr.v ":class") (Some (Jstr.v colon_class_str)) result; + (* Note x-on:click is another way of doing @click + https://github.com/alpinejs/alpine/issues/396 + *) + El.set_at (Jstr.v "x-on:click") (Some (Jstr.v "highlightLine")) result; + result + +let tabulate line_number first_line_repro_block last_line_repro_block data = + let last_line_blank = ref false in + let aux log_line = + if !last_line_blank && log_line = "" then + (* Squash consecutive new lines *) + None + else ( + line_number := !line_number + 1; + let is_start_of_steps_to_reproduce = + Astring.String.is_infix ~affix:"To reproduce locally:" log_line + in + let is_end_of_steps_to_reproduce = + Astring.String.is_infix ~affix:"END-REPRO-BLOCK" log_line + in + let code_line_class = + if is_start_of_steps_to_reproduce then ( + first_line_repro_block := !line_number; + "repro-block-start") + else if is_end_of_steps_to_reproduce then ( + last_line_repro_block := !line_number; + "repro-block-end") + else "" + in + last_line_blank := log_line = ""; + let line_number_id = Printf.sprintf "L%d" !line_number in + let line = to_element ~line_number_id ~log_line ~code_line_class in + Some line) + in + List.filter_map aux data + +let go line_number first_line_repro_block last_line_repro_block data = + Astring.String.(with_range ~len:(length data - 1)) data + |> Astring.String.cuts ~sep:"\n" + |> List.map (fun l -> collapse_carriage_returns l |> Ansi.process ansi) + |> tabulate line_number first_line_repro_block last_line_repro_block diff --git a/web-ui/jsoo/steps_to_reproduce_build.ml b/web-ui/jsoo/steps_to_reproduce_build.ml new file mode 100644 index 00000000..fd4a6a2f --- /dev/null +++ b/web-ui/jsoo/steps_to_reproduce_build.ml @@ -0,0 +1,15 @@ +module Ev = Brr.Ev +module El = Brr.El +module At = Brr.At +module Document = Brr.Document +module Window = Brr.Window + +let go first_line_repro_block last_line_repro_block = + (* extractStepsToReproduce is declared in step-page-poll.js + Until it is cut across to jsoo/brr call it directly *) + let extractStepsToReproduce' = Jv.get Jv.global "extractStepsToReproduce" in + let _ = + Jv.apply extractStepsToReproduce' + [| Jv.of_int first_line_repro_block; Jv.of_int last_line_repro_block |] + in + () diff --git a/web-ui/router.ml b/web-ui/router.ml index 5a8846de..a4d9315c 100644 --- a/web-ui/router.ml +++ b/web-ui/router.ml @@ -48,6 +48,18 @@ struct ~variant:(Dream.param request "variant") request F.backend) + let variant_websockets = + let url = + Printf.sprintf "/ws/%s/:org/:repo/commit/:hash/variant/:variant" F.prefix + in + Dream.get url (fun request -> + F.Controller.ws_show_step + ~org:(Dream.param request "org") + ~repo:(Dream.param request "repo") + ~hash:(Dream.param request "hash") + ~variant:(Dream.param request "variant") + request F.backend) + let branch_history = let url = Printf.sprintf "/%s/:org/:repo/history/branch/**" F.prefix in Dream.get url (fun request -> @@ -208,6 +220,7 @@ struct rebuild_variant; build_api; variant_api; + variant_websockets; badge; ] @ F.extra_routes diff --git a/web-ui/static/css/style.css b/web-ui/static/css/style.css index 669d9820..3cc2b6f4 100644 --- a/web-ui/static/css/style.css +++ b/web-ui/static/css/style.css @@ -10,6 +10,7 @@ html { body { font-family: "Inter", sans-serif; @apply bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-100; + counter-reset: linenum; } .container-fluid { @@ -330,3 +331,13 @@ pre.code span.tr:last-child span.th { .svg-fill { @apply fill-gray-900 dark:fill-gray-100; } + +/* https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/ */ +#logs-scroller * { + overflow-anchor: none; +} + +#anchor { + overflow-anchor: auto; + height: 1px; +} \ No newline at end of file diff --git a/web-ui/static/js/add-repro-steps.js b/web-ui/static/js/add-repro-steps.js deleted file mode 100644 index 2e80346f..00000000 --- a/web-ui/static/js/add-repro-steps.js +++ /dev/null @@ -1,28 +0,0 @@ -function extractStepsToReproduce() { - const tables = document.getElementsByClassName("steps-table"); - const start = document.getElementsByClassName("repro-block-start"); - const finish = document.getElementsByClassName("repro-block-end"); - if ((start.length === 0) || (finish.length === 0) || (tables.length === 0)) { - document.getElementById("build-repro-container").style.display = 'none'; - return null - }; - const startIndex = parseInt(start[0].parentElement.id.split('L')[1], 10); - const finishIndex = parseInt(finish[0].parentElement.id.split('L')[1], 10); - - const br = document.createElement("br"); - const reproDiv = document.getElementById("build-repro"); - const rows = [...tables].map((t) => Array.from(t.children)).flat(1); - - document.getElementById("build-repro-container").style.removeProperty('display'); - - for (let i = (startIndex); i < (finishIndex - 1); i++) { - // Remove line-number, whitespace and possible newlines from the row - const newContent = document.createTextNode(rows[i].innerText.replace(/\s*\n?/, '')); - reproDiv.appendChild(newContent); - if (i > startIndex) { - reproDiv.appendChild(br.cloneNode()); - } - } -} - -extractStepsToReproduce(); diff --git a/web-ui/static/js/dune b/web-ui/static/js/dune new file mode 100644 index 00000000..5379df1c --- /dev/null +++ b/web-ui/static/js/dune @@ -0,0 +1,7 @@ +(rule + (deps ../../jsoo/main.bc.js) + (target ./step-logs.js) + (mode + (promote (until-clean))) + (action + (copy %{deps} %{target}))) diff --git a/web-ui/static/js/step-page-poll.js b/web-ui/static/js/step-page-poll.js index dbdeb2f4..6a476a0c 100644 --- a/web-ui/static/js/step-page-poll.js +++ b/web-ui/static/js/step-page-poll.js @@ -1,3 +1,32 @@ +// Called via jsoo in the generated step-logs.js +function extractStepsToReproduce(startIndex, finishIndex) { + if ( // return if already done + !!document.getElementById("build-repro-container") && + document.getElementById("build-repro-container").style.display != "none" + ) { + return null; + } + const tables = document.getElementsByClassName("steps-table"); + const br = document.createElement("br"); + const reproDiv = document.getElementById("build-repro"); + const rows = [...tables].map((t) => Array.from(t.children)).flat(1); + + document + .getElementById("build-repro-container") + .style.removeProperty("display"); + + for (let i = startIndex; i < finishIndex - 1; i++) { + // Remove line-number, whitespace and possible newlines from the row + const newContent = document.createTextNode( + rows[i].innerText.replace(/\s*\n?/, "") + ); + reproDiv.appendChild(newContent); + if (i > startIndex) { + reproDiv.appendChild(br.cloneNode()); + } + } +} + function poll(api_path, timeout, interval) { var endTime = Number(new Date()) + (timeout || 2700000); // 45min timeout interval = interval || 10000; // 10s @@ -21,16 +50,19 @@ function poll(api_path, timeout, interval) { fetch(api_path) .then((response) => response.json()) .then((data) => { - console.log(data); - const element_created_at = document.getElementById("step-created-at"); - element_created_at.innerHTML = "Created at " + data["created_at"]; + !!data["created_at"] && + (element_created_at.innerHTML = "Created at " + data["created_at"]); const element_finished_at = document.getElementById("step-finished-at"); - element_finished_at.innerHTML = "Finished at " + data["finished_at"]; + !!data["finished_at"] && + (element_finished_at.innerHTML = + "Finished at " + data["finished_at"]); const element_ran_for = document.getElementById("step-ran-for"); - element_ran_for.innerHTML = "Ran for " + data["ran_for"]; + !!data["ran_for"] && + (element_ran_for.innerHTML = "Ran for " + data["ran_for"]); const element_queued_for = document.getElementById("step-queued-for"); - element_queued_for.innerHTML = data["queued_for"] + " in queue"; + !!data["queued_for"] && + (element_queued_for.innerHTML = data["queued_for"] + " in queue"); if ( data["status"].startsWith("passed") || @@ -47,7 +79,7 @@ function poll(api_path, timeout, interval) { .getElementById("rebuild-step") .style.removeProperty("display"); } - if (data["can_cancel"]) { + if (data["can_cancel"]) { document .getElementById("cancel-step") .style.removeProperty("display"); @@ -70,5 +102,6 @@ function poll(api_path, timeout, interval) { return new Promise(checkCondition); } -// Usage: ensure element is visible -poll(location.origin + "/api" + location.pathname); +window.onload = function () { + poll(location.origin + "/api" + location.pathname); +}; diff --git a/web-ui/view/dune b/web-ui/view/dune index e598ad97..cd54e267 100644 --- a/web-ui/view/dune +++ b/web-ui/view/dune @@ -1,4 +1,4 @@ (library (public_name ocaml-ci-web.view) (name view) - (libraries ansi unix fmt dream tyxml ocaml-ci timedesc)) + (libraries ansi unix fmt dream tyxml ocaml-ci ocaml-ci-api timedesc)) diff --git a/web-ui/view/git_forge_intf.ml b/web-ui/view/git_forge_intf.ml index bb157b5b..e9623048 100644 --- a/web-ui/view/git_forge_intf.ml +++ b/web-ui/view/git_forge_intf.ml @@ -97,7 +97,7 @@ module type View = sig can_cancel:bool -> ?flash_messages:(string * string) list -> string * int64 -> - Dream.response Lwt.t + string end module type S = sig diff --git a/web-ui/view/step.ml b/web-ui/view/step.ml index 8bf4e204..40dc9ca7 100644 --- a/web-ui/view/step.ml +++ b/web-ui/view/step.ml @@ -193,460 +193,358 @@ module Make (M : Git_forge_intf.Forge) = struct let show ~org ~repo ~refs ~hash ~variant ~job ~status ~csrf_token ~timestamps ~build_created_at ~step_created_at ~step_finished_at ~can_rebuild ~can_cancel ?(flash_messages = []) (data, next) = + ignore job; + ignore data; + ignore next; let show_rebuild = (not can_cancel) && can_rebuild in - let header, footer = - let buttons = - [ - Common.form_cancel_step ~variant ~csrf_token ~show:can_cancel (); - Common.form_rebuild_step ~variant ~csrf_token ~show:show_rebuild (); - ] - in - let branch = - if refs = [] then "" - else - match Astring.String.cuts ~sep:"/" (List.hd refs) with - | "refs" :: "heads" :: branch -> Astring.String.concat ~sep:"/" branch - | _ -> "" - in - let build_created_at = Option.value ~default:0. build_created_at in - let run_time = - Option.map (Run_time.TimeInfo.of_timestamp ~build_created_at) timestamps - in - let title_card = - title_card ~status ~card_title:variant - ~hash_link: - (link_forge_commit ~org ~repo ~hash:(Common.short_hash hash)) - ~created_at:(Run_time.Duration.pp_readable_opt step_created_at) - ~finished_at:(Run_time.Duration.pp_readable_opt step_finished_at) - ~queued_for: - (Run_time.Duration.pp_opt - (Option.map Run_time.TimeInfo.queued_for run_time)) - ~ran_for: - (Run_time.Duration.pp_opt - (Option.map Run_time.TimeInfo.ran_for run_time)) - ~buttons - in - let steps_to_reproduce_build = - Tyxml.Html.( - div - ~a: - [ - a_class - [ - "shadow-sm rounded-lg overflow-hidden border \ - border-gray-200 dark:border-gray-400 divide-x \ - divide-gray-20"; - ]; - a_style "display: none"; - a_id "build-repro-container"; - ] + let buttons = + [ + Common.form_cancel_step ~variant ~csrf_token ~show:can_cancel (); + Common.form_rebuild_step ~variant ~csrf_token ~show:show_rebuild (); + ] + in + let branch = + if refs = [] then "" + else + match Astring.String.cuts ~sep:"/" (List.hd refs) with + | "refs" :: "heads" :: branch -> Astring.String.concat ~sep:"/" branch + | _ -> "" + in + let build_created_at = Option.value ~default:0. build_created_at in + let run_time = + Option.map (Run_time.TimeInfo.of_timestamp ~build_created_at) timestamps + in + let title_card = + title_card ~status ~card_title:variant + ~hash_link:(link_forge_commit ~org ~repo ~hash:(Common.short_hash hash)) + ~created_at:(Run_time.Duration.pp_readable_opt step_created_at) + ~finished_at:(Run_time.Duration.pp_readable_opt step_finished_at) + ~queued_for: + (Run_time.Duration.pp_opt + (Option.map Run_time.TimeInfo.queued_for run_time)) + ~ran_for: + (Run_time.Duration.pp_opt + (Option.map Run_time.TimeInfo.ran_for run_time)) + ~buttons + in + let steps_to_reproduce_build = + Tyxml.Html.( + div + ~a: [ - div - ~a: - [ - a_class - [ - "flex items-center justify-between px-4 py-3 \ - bg-gray-50 dark:bg-gray-850"; - ]; - ] + a_class [ - div - ~a: - [ - Tyxml_helpers.at_click "stepsToRepro = !stepsToRepro"; - a_class - [ - "text-gray-900 dark:text-gray-200 text-base \ - font-medium border-b-none border-gray-200 flex \ - items-center space-x-3 flex-1 cursor-pointer"; - ]; - ] + "shadow-sm rounded-lg overflow-hidden border border-gray-200 \ + dark:border-gray-400 divide-x divide-gray-20"; + ]; + a_style "display: none"; + a_id "build-repro-container"; + ] + [ + div + ~a: + [ + a_class [ - Tyxml.Svg.( - Tyxml.Html.svg - ~a: - [ - a_class [ "h-5 w-5 rotate-180" ]; - Tyxml_helpers.a_svg_custom ":class" - "{ 'rotate-180': stepsToRepro == 1 }"; - a_fill `None; - a_viewBox (0., 0., 24., 24.); - a_stroke `CurrentColor; - a_stroke_width (2., Some `Px); - ] - [ - path - ~a: - [ - a_stroke_linecap `Round; - a_stroke_linejoin `Round; - a_d "M19 9l-7 7-7-7"; - ] - []; - ]); - div [ txt "Steps to Reproduce" ]; + "flex items-center justify-between px-4 py-3 bg-gray-50 \ + dark:bg-gray-850"; ]; - div + ] + [ + div + ~a: [ - Tyxml.Html.button - ~a: - [ - a_class [ "btn btn-sm btn-default" ]; - Tyxml_helpers.at_click - "codeCopied = true, \ - $clipboard($refs.reproCode.innerText)"; - ] + Tyxml_helpers.at_click "stepsToRepro = !stepsToRepro"; + a_class [ - Tyxml.Svg.( - Tyxml.Html.svg - ~a: - [ - a_class [ "h-5 w-5" ]; - a_fill `None; - a_viewBox (0., 0., 24., 24.); - a_stroke `CurrentColor; - a_stroke_width (2., Some `Px); - ] - [ - path - ~a: - [ - a_stroke_linecap `Round; - a_stroke_linejoin `Round; - a_d - "M8 16H6a2 2 0 01-2-2V6a2 2 0 \ - 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 \ - 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 \ - 2v8a2 2 0 002 2z"; - ] - []; - ]); - txt "Copy code"; + "text-gray-900 dark:text-gray-200 text-base \ + font-medium border-b-none border-gray-200 flex \ + items-center space-x-3 flex-1 cursor-pointer"; ]; - ]; - ]; - div - ~a: + ] [ - a_class - [ - "flex relative overflow-hidden transition-all max-h-0 \ - duration-700"; - ]; - Tyxml_helpers.x_ref "container1"; - Tyxml_helpers.x_bind_style - "stepsToRepro == 1 ? 'max-height: ' + \ - ($refs.container1.scrollHeight + 20) + 'px' : ''"; - ] - [ - div - ~a: - [ - a_class - [ - "fg-default bg-default px-6 py-3 rounded-lg \ - rounded-l-none text-gray-300 w-full \ - rounded-t-none"; - ]; - ] - [ - Tyxml.Html.code + Tyxml.Svg.( + Tyxml.Html.svg ~a: [ - a_id "build-repro"; - a_class [ "overflow-auto" ]; - Tyxml_helpers.x_ref "reproCode"; + a_class [ "h-5 w-5 rotate-180" ]; + Tyxml_helpers.a_svg_custom ":class" + "{ 'rotate-180': stepsToRepro == 1 }"; + a_fill `None; + a_viewBox (0., 0., 24., 24.); + a_stroke `CurrentColor; + a_stroke_width (2., Some `Px); ] - []; - ]; - ]; - ]) - in - let logs_container = - Tyxml.Html.( - div - ~a: - [ - a_class [ "mt-6 bg-gray-100 rounded-lg relative border" ]; - Tyxml_helpers.x_data "codeLink"; - Tyxml_helpers.x_init "highlightLine"; - ] - [ - Tyxml.Html.button - ~a: + [ + path + ~a: + [ + a_stroke_linecap `Round; + a_stroke_linejoin `Round; + a_d "M19 9l-7 7-7-7"; + ] + []; + ]); + div [ txt "Steps to Reproduce" ]; + ]; + div [ - a_class [ "copy-link-btn" ]; - Tyxml_helpers.at_click "copyCode"; - Tyxml_helpers.x_show "manualSelection"; - Tyxml_helpers.x_ref "copyLinkBtn"; - Tyxml_helpers.x_cloak; - ] - [ - Tyxml.Svg.( - Tyxml.Html.svg + Tyxml.Html.button ~a: [ - a_class [ "w-4 h-4" ]; - a_fill `None; - a_viewBox (0., 0., 24., 24.); - a_stroke_width (2., Some `Px); - a_stroke `CurrentColor; + a_class [ "btn btn-sm btn-default" ]; + Tyxml_helpers.at_click + "codeCopied = true, \ + $clipboard($refs.reproCode.innerText)"; ] [ - path - ~a: + Tyxml.Svg.( + Tyxml.Html.svg + ~a: + [ + a_class [ "h-5 w-5" ]; + a_fill `None; + a_viewBox (0., 0., 24., 24.); + a_stroke `CurrentColor; + a_stroke_width (2., Some `Px); + ] [ - a_stroke_linecap `Round; - a_stroke_linejoin `Round; - a_d - "M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 \ - 4.5a4.5 4.5 0 \ - 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 \ - 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 \ - 001.242 7.244"; - ] - []; - ]); - ]; - div - ~a: - [ - a_class - [ - "table-overflow overflow-auto rounded-lg fg-default \ - bg-default"; + path + ~a: + [ + a_stroke_linecap `Round; + a_stroke_linejoin `Round; + a_d + "M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 \ + 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 \ + 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"; + ] + []; + ]); + txt "Copy code"; ]; - ] - [ - pre - ~a: - [ - a_class - [ "flex code steps-table fg-default bg-default" ]; - ] - [ txt "@@@" ]; - ]; - ]) - in - let notification ~variable ~text = - Tyxml.Html.( - div - ~a: - [ - a_class [ "notification dark:bg-gray-850" ]; - Tyxml_helpers.x_cloak; - Tyxml_helpers.x_show variable; - Tyxml_helpers.x_transition; - ] - [ - div - ~a:[ a_class [ "flex items-center space-x-2" ] ] + ]; + ]; + div + ~a: [ - div - ~a:[ a_class [ "icon-status icon-status--success" ] ] + a_class [ - Tyxml.Svg.( - Tyxml.Html.svg - ~a: - [ - a_class [ "h-4 w-4" ]; - a_viewBox (0., 0., 20., 20.); - a_fill (`Color ("#12B76A", None)); - ] - [ - path - ~a: - [ - Tyxml_helpers.a_svg_custom "fill-rule" - "evenodd"; - Tyxml_helpers.a_svg_custom "clip-rule" - "evenodd"; - a_d - "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 \ - 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 \ - 12.586l7.293-7.293a1 1 0 011.414 0z"; - ] - []; - ]); + "flex relative overflow-hidden transition-all max-h-0 \ + duration-700"; ]; - div [ txt text ]; - ]; - Tyxml.Html.button - ~a: + Tyxml_helpers.x_ref "container1"; + Tyxml_helpers.x_bind_style + "stepsToRepro == 1 ? 'max-height: ' + \ + ($refs.container1.scrollHeight + 20) + 'px' : ''"; + ] + [ + div + ~a: + [ + a_class + [ + "fg-default bg-default px-6 py-3 rounded-lg \ + rounded-l-none text-gray-300 w-full rounded-t-none"; + ]; + ] [ - a_class [ "icon-button" ]; - Tyxml_helpers.at_click (Printf.sprintf "%s=false" variable); - ] - [ - Tyxml.Svg.( - Tyxml.Html.svg + Tyxml.Html.code ~a: [ - a_fill `None; - a_viewBox (0., 0., 24., 24.); - a_stroke_width (2.5, Some `Px); - a_stroke `CurrentColor; - a_class [ "w-4 h-4" ]; + a_id "build-repro"; + a_class [ "overflow-auto" ]; + Tyxml_helpers.x_ref "reproCode"; ] - [ - path - ~a: - [ - a_stroke_linecap `Round; - a_stroke_linejoin `Round; - a_d "M4.5 19.5l15-15m-15 0l15 15"; - ] - []; - ]); - ]; - ]) - in - let body = - Template.instance ~full:true + []; + ]; + ]; + ]) + in + let logs_container = + Tyxml.Html.( + div + ~a: + [ + a_class [ "mt-6 bg-gray-100 rounded-lg relative border" ]; + Tyxml_helpers.x_data "codeLink"; + Tyxml_helpers.x_init "highlightLine"; + ] [ - Tyxml.Html.script ~a:[ a_src "/js/log-highlight.js" ] (txt ""); - Tyxml.Html.script ~a:[ a_src "/js/step-page-poll.js" ] (txt ""); - Common.breadcrumbs + Tyxml.Html.button + ~a: + [ + a_class [ "copy-link-btn" ]; + Tyxml_helpers.at_click "copyCode"; + Tyxml_helpers.x_show "manualSelection"; + Tyxml_helpers.x_ref "copyLinkBtn"; + Tyxml_helpers.x_cloak; + ] [ - ("Organisations", M.prefix); - (org, org); - (repo, repo); - ( Printf.sprintf "%s (%s)" (Common.short_hash hash) branch, - Printf.sprintf "commit/%s" hash ); - ] - variant; - title_card; - Common.flash_messages flash_messages; + Tyxml.Svg.( + Tyxml.Html.svg + ~a: + [ + a_class [ "w-4 h-4" ]; + a_fill `None; + a_viewBox (0., 0., 24., 24.); + a_stroke_width (2., Some `Px); + a_stroke `CurrentColor; + ] + [ + path + ~a: + [ + a_stroke_linecap `Round; + a_stroke_linejoin `Round; + a_d + "M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 \ + 4.5a4.5 4.5 0 \ + 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 \ + 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 \ + 7.244"; + ] + []; + ]); + ]; div ~a: [ - a_class [ "mt-8 flex flex-col" ]; - Tyxml_helpers.x_data - "{ url: window.location.href, logs: true, artefacts: \ - false, codeCoverage: false, codeCopied: false, \ - linkCopied: false, startingLine: null, endingLine: null, \ - manualSelection: false}"; + (* this id is referenced in the jsoo script *) + a_id "logs-scroller"; + a_class + [ + "table-overflow overflow-auto rounded-lg fg-default \ + bg-default"; + ]; ] [ - notification ~variable:"linkCopied" ~text:"Link Copied"; - notification ~variable:"codeCopied" ~text:"Code Copied"; - div + pre ~a: [ - a_class - [ - "flex space-x-6 border-b border-gray-200 mb-6 text-sm"; - ]; + a_id "logs-pre"; + a_class [ "flex code steps-table fg-default bg-default" ]; ] - [ h3 ~a:[ a_class [ "font-medium pb-2" ] ] [ txt "Logs" ] ]; + []; + div ~a:[ a_id "anchor" ] []; + (* https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/ *) + ]; + ]) + in + let notification ~variable ~text = + Tyxml.Html.( + div + ~a: + [ + a_class [ "notification dark:bg-gray-850" ]; + Tyxml_helpers.x_cloak; + Tyxml_helpers.x_show variable; + Tyxml_helpers.x_transition; + ] + [ + div + ~a:[ a_class [ "flex items-center space-x-2" ] ] + [ div - ~a: + ~a:[ a_class [ "icon-status icon-status--success" ] ] + [ + Tyxml.Svg.( + Tyxml.Html.svg + ~a: + [ + a_class [ "h-4 w-4" ]; + a_viewBox (0., 0., 20., 20.); + a_fill (`Color ("#12B76A", None)); + ] + [ + path + ~a: + [ + Tyxml_helpers.a_svg_custom "fill-rule" "evenodd"; + Tyxml_helpers.a_svg_custom "clip-rule" "evenodd"; + a_d + "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 \ + 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 \ + 12.586l7.293-7.293a1 1 0 011.414 0z"; + ] + []; + ]); + ]; + div [ txt text ]; + ]; + Tyxml.Html.button + ~a: + [ + a_class [ "icon-button" ]; + Tyxml_helpers.at_click (Printf.sprintf "%s=false" variable); + ] + [ + Tyxml.Svg.( + Tyxml.Html.svg + ~a: + [ + a_fill `None; + a_viewBox (0., 0., 24., 24.); + a_stroke_width (2.5, Some `Px); + a_stroke `CurrentColor; + a_class [ "w-4 h-4" ]; + ] [ - Tyxml_helpers.x_show "logs"; - Tyxml_helpers.x_data "{stepsToRepro: false}"; - ] - [ steps_to_reproduce_build; logs_container ]; + path + ~a: + [ + a_stroke_linecap `Round; + a_stroke_linejoin `Round; + a_d "M4.5 19.5l15-15m-15 0l15 15"; + ] + []; + ]); ]; - Tyxml.Html.script ~a:[ a_src "/js/add-repro-steps.js" ] (txt ""); - ] - in - Astring.String.cut ~sep:"@@@" body |> Option.get + ]) in - let ansi = Ansi.create () in - let line_number = ref 0 in - let last_line_blank = ref false in - let tabulate data : string = - let aux log_line = - if !last_line_blank && log_line = "" then - (* Squash consecutive new lines *) - None - else - let is_start_of_steps_to_reproduce = - Astring.String.is_infix ~affix:"To reproduce locally:" log_line - in - let is_end_of_steps_to_reproduce = - Astring.String.is_infix ~affix:"END-REPRO-BLOCK" log_line - in - let code_line_class = - if is_start_of_steps_to_reproduce then "repro-block-start" - else if is_end_of_steps_to_reproduce then "repro-block-end" - else "" - in - last_line_blank := log_line = ""; - line_number := !line_number + 1; - let line_number_id = Printf.sprintf "L%d" !line_number in - let line = - Fmt.str "%a" (pp_elt ()) - (span - ~a: - [ - a_class [ "tr" ]; - Tyxml_helpers.colon_class - "parseInt($el.id.substring(1, $el.id.length)) >= \ - startingLine && parseInt($el.id.substring(1, \ - $el.id.length)) <= endingLine ? 'highlight' : ''"; - Tyxml_helpers.at_click "highlightLine"; - a_id line_number_id; - ] - [ - span - ~a: - [ - a_class [ "th" ]; - a_user_data "line-number" line_number_id; - ] - []; - code - ~a: - [ - a_class [ code_line_class ]; - a_user_data "line-number" line_number_id; - ] - [ Unsafe.data log_line ]; - ]) - in - Some line - in - List.filter_map aux data |> String.concat "\n" - in - let collapse_carriage_returns log_line = - let rec last = function - | [] -> raise (Failure "Trying to take log_line from empty list (BUG)") - | [ s ] -> s - | _ :: l -> last l - in - match log_line with - | "" -> "" - | log_line -> Astring.String.cuts ~sep:"\r" log_line |> last - in - let process_logs data = - Astring.String.(with_range ~len:(length data - 1)) data - |> Astring.String.cuts ~sep:"\n" - |> List.map (fun l -> collapse_carriage_returns l |> Ansi.process ansi) - |> tabulate - in - let open Lwt.Infix in - Dream.stream - ~headers:[ ("Content-type", "text/html; charset=utf-8") ] - (fun response_stream -> - Dream.write response_stream header >>= fun () -> - let data' = process_logs data in - Dream.write response_stream data' >>= fun () -> - let rec loop next = - Current_rpc.Job.log job ~start:next >>= function - | Ok ("", _) -> - Dream.write response_stream footer >>= fun () -> - Dream.close response_stream - | Ok (data, next) -> - Dream.log "Fetching logs"; - let data' = process_logs data in - Dream.write response_stream data' >>= fun () -> - Dream.flush response_stream >>= fun () -> loop next - | Error (`Capnp ex) -> - Dream.log "Error fetching logs: %a" Capnp_rpc.Error.pp ex; - Dream.write response_stream - (Fmt.str "ocaml-ci error: %a@." Capnp_rpc.Error.pp ex) - in - loop next) + (* let body = *) + Template.instance ~full:true + [ + Tyxml.Html.script ~a:[ a_src "/js/log-highlight.js" ] (txt ""); + Tyxml.Html.script ~a:[ a_src "/js/step-page-poll.js" ] (txt ""); + Tyxml.Html.script ~a:[ a_src "/js/step-logs.js" ] (txt ""); + Common.breadcrumbs + [ + ("Organisations", M.prefix); + (org, org); + (repo, repo); + ( Printf.sprintf "%s (%s)" (Common.short_hash hash) branch, + Printf.sprintf "commit/%s" hash ); + ] + variant; + title_card; + Common.flash_messages flash_messages; + div + ~a: + [ + a_class [ "mt-8 flex flex-col" ]; + Tyxml_helpers.x_data + "{ url: window.location.href, logs: true, artefacts: false, \ + codeCoverage: false, codeCopied: false, linkCopied: false, \ + startingLine: null, endingLine: null, manualSelection: false}"; + ] + [ + notification ~variable:"linkCopied" ~text:"Link Copied"; + notification ~variable:"codeCopied" ~text:"Code Copied"; + div + ~a: + [ + a_class + [ "flex space-x-6 border-b border-gray-200 mb-6 text-sm" ]; + ] + [ h3 ~a:[ a_class [ "font-medium pb-2" ] ] [ txt "Logs" ] ]; + div + ~a: + [ + Tyxml_helpers.x_show "logs"; + Tyxml_helpers.x_data "{stepsToRepro: false}"; + ] + [ steps_to_reproduce_build; logs_container ]; + ]; + ] end diff --git a/web-ui/view/step.mli b/web-ui/view/step.mli index 62c16f4b..7d064ccc 100644 --- a/web-ui/view/step.mli +++ b/web-ui/view/step.mli @@ -32,5 +32,5 @@ module Make : functor (_ : Git_forge_intf.Forge) -> sig can_cancel:bool -> ?flash_messages:(string * string) list -> string * int64 -> - Dream.response Lwt.t + string end