From de7ce9b47d4b7675cd94b22caf1d981204a18f90 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Wed, 21 Feb 2024 13:09:40 +0530 Subject: [PATCH] feat: add functioning / barebones version of the app --- app/logic/api_utils.R | 184 ++++++++++++++++++++++++++++++++++++ app/logic/app_table_utils.R | 55 +++++++++++ app/logic/general_utils.R | 39 ++++++++ app/logic/job_list_utils.R | 47 +++++++++ app/logic/logs_utils.R | 55 +++++++++++ app/main.R | 119 +++++++++++++++++++++-- app/static/css/app.min.css | 1 + app/static/empty_state.svg | 1 + app/styles/_app_table.scss | 23 +++++ app/styles/_colors.scss | 39 ++++++++ app/styles/_dashboard.scss | 64 +++++++++++++ app/styles/_job_list.scss | 10 ++ app/styles/_logs.scss | 64 +++++++++++++ app/styles/main.scss | 5 + app/view/__init__.R | 2 - app/view/mod_app_table.R | 94 ++++++++++++++++++ app/view/mod_job_list.R | 96 +++++++++++++++++++ app/view/mod_logs.R | 131 +++++++++++++++++++++++++ dependencies.R | 5 +- renv.lock | 50 ++++++++++ 20 files changed, 1070 insertions(+), 14 deletions(-) create mode 100644 app/logic/api_utils.R create mode 100644 app/logic/app_table_utils.R create mode 100644 app/logic/general_utils.R create mode 100644 app/logic/job_list_utils.R create mode 100644 app/logic/logs_utils.R create mode 100644 app/static/css/app.min.css create mode 100644 app/static/empty_state.svg create mode 100644 app/styles/_app_table.scss create mode 100644 app/styles/_colors.scss create mode 100644 app/styles/_dashboard.scss create mode 100644 app/styles/_job_list.scss create mode 100644 app/styles/_logs.scss delete mode 100644 app/view/__init__.R create mode 100644 app/view/mod_app_table.R create mode 100644 app/view/mod_job_list.R create mode 100644 app/view/mod_logs.R diff --git a/app/logic/api_utils.R b/app/logic/api_utils.R new file mode 100644 index 0000000..3ee0fc5 --- /dev/null +++ b/app/logic/api_utils.R @@ -0,0 +1,184 @@ +box::use( + httr2[ + request, + req_auth_bearer_token, + req_dry_run, + req_perform, + resp_body_string, + req_user_agent + ], + magrittr[`%>%`], + jsonlite[fromJSON], + dplyr[filter], + glue[glue] +) + +#' Simple function to get the access token from environment +#' @return Character token, if present +get_access_token <- function() { + token <- Sys.getenv( + "CONNECT_API_KEY", + unset = "NO_TOKEN" + ) + if (token == "NO_TOKEN" || token == "") { + stop("Are you sure your CONNECT_API_KEY is set?") + } else { + token + } +} + +#' Simple function to make an API url +#' @param host Character. Default CONNECT_SERVER set as an envvar +#' @param endpoint Character. Default is "content" +#' @param versioned Logical. Whether to use versioned API. Default is FALSE +#' @return url for the API +get_api_url <- function( + host = Sys.getenv("CONNECT_SERVER"), + endpoint = "content", + version = "v1" +) { + glue("{host}__api__/{version}/{endpoint}/") +} + +#' Function to get a list of all apps belonging to the token +#' +#' @param app_mode_filter Character. The filter for app_mode in the API +#' response. Default is "shiny". +#' @param url Character. The URL for API endpoint +#' @param dry_run Logical. Whether to dry run the API for debugging. +#' Default is FALSE +#' @export +get_app_list <- function( + app_mode_filter = "shiny", + endpoint = "content", + dry_run = FALSE +) { + + url <- get_api_url( + endpoint = endpoint + ) + + api_request <- request(url) %>% + req_user_agent("LogAnalyzer") %>% + req_auth_bearer_token(get_access_token()) + + if (dry_run) { + api_request %>% + req_dry_run() + } else { + api_request %>% + req_perform() %>% + resp_body_string() %>% + fromJSON() %>% + filter(app_mode == app_mode_filter) + } +} + +#' Function to get a list of all jobs for a specific app +#' +#' @param guid Character. The guid for the app in question +#' @param url Character. The URL for API endpoint +#' @param dry_run Logical. Whether to dry run the API for debugging. +#' Default is FALSE +#' @export +get_job_list <- function( + guid = NULL, + endpoint = "content", + dry_run = FALSE +) { + + url <- get_api_url( + endpoint = endpoint + ) + + api_request <- request( + glue("{url}{guid}/jobs") + ) %>% + req_user_agent("LogAnalyzer") %>% + req_auth_bearer_token(get_access_token()) + + if (dry_run) { + api_request %>% + req_dry_run() + } else { + api_request %>% + req_perform() %>% + resp_body_string() %>% + fromJSON() + } +} + +#' Function to get a list of all logs for a job for a specific app +#' +#' @param guid Character. The guid for the app in question +#' @param job_key Character. The key for the job in question +#' @param url Character. The URL for API endpoint +#' @param tail Logical. Whether to show the tail only for the logs +#' @param dry_run Logical. Whether to dry run the API for debugging. +#' Default is FALSE +#' @export +get_job_logs <- function( + guid = NULL, + job_key = NULL, + endpoint = "content", + tail = FALSE, + dry_run = FALSE +) { + + url <- get_api_url( + endpoint = endpoint + ) + + api_request <- request( + glue("{url}{guid}/jobs/{job_key}/{ifelse(tail, 'tail', 'log')}") + ) %>% + req_user_agent("LogAnalyzer") %>% + req_auth_bearer_token(get_access_token()) + + if (dry_run) { + api_request %>% + req_dry_run() + } else { + logs <- api_request %>% + req_perform() %>% + resp_body_string() %>% + fromJSON() + logs["entries"] %>% + data.frame() + } +} + +#' Function to download the logfile for a specific app +#' +#' @param guid Character. The guid for the app in question +#' @param job_key Character. The key for the job in quesrtion +#' @param url Character. The URL for API endpoint +#' @param dry_run Logical. Whether to dry run the API for debugging. +#' Default is FALSE +#' @export +download_job_logs <- function( + guid = NULL, + job_key = NULL, + endpoint = "content", + dry_run = FALSE +) { + + url <- get_api_url( + endpoint = endpoint + ) + + api_request <- request( + glue("{url}{guid}/jobs/{job_key}/download") + ) %>% + req_user_agent("LogAnalyzer") %>% + req_auth_bearer_token(get_access_token()) + + if (dry_run) { + api_request %>% + req_dry_run() + } else { + api_request %>% + req_perform() %>% + resp_body_string() + } +} diff --git a/app/logic/app_table_utils.R b/app/logic/app_table_utils.R new file mode 100644 index 0000000..9080095 --- /dev/null +++ b/app/logic/app_table_utils.R @@ -0,0 +1,55 @@ +box::use( + shiny[ + div, + span, + icon, + a, + strong + ], + glue[glue] +) + +box::use( + app/logic/general_utils[format_timestamp] +) + +#' Function to process each row for the app table +#' This creates the HTML for the row +#' +#' @export +process_app_data <- function( + app_data +) { + app_info <- strsplit(app_data, "_-_")[[1]] + div( + class = "app-entry", + div( + class = "app-title", + span( + app_info[1], + a( + href = app_info[3], + class = "app-link", + icon( + name = "arrow-up-right-from-square", + class = "app-link-icon" + ), + target = "_blank" + ) + ) + ), + div( + class = "app-metadata", + div( + class = "app-last-deployed", + strong("Last Deployed: "), + format_timestamp(app_info[4]) + ), + span( + class = "app-r-version", + strong("R version: "), + app_info[2] + ) + ) + ) +} diff --git a/app/logic/general_utils.R b/app/logic/general_utils.R new file mode 100644 index 0000000..8095a00 --- /dev/null +++ b/app/logic/general_utils.R @@ -0,0 +1,39 @@ +#' Function to check if a string of log text has error keywords +#' +#' @param text Character. The log string +#' @param wordlist Character vector. List of keywords to scan. Default +#' list is `c("halt", "err", "terminat", "not found")` +#' @param ignore_case Logical. Whether to ignore the case for words +#' Default is TRUE +#' @export +check_text_error <- function( + text, + wordlist = c("halt", "err", "terminat", "not found"), + ignore_case = TRUE +) { + grepl( + paste(wordlist, collapse = "|"), + text, + ignore.case = ignore_case + ) +} + +#' Function to convert timestamp between formats +#' +#' @param timestamp Character. The timestamp string +#' @param from Character. Original format. Default is "%Y-%m-%dT%H:%M:%OSZ" +#' @param to Character. New format. Default is "%Y-%m-%d %H:%M:%S" +#' @export +format_timestamp <- function( + timestamp, + from = "%Y-%m-%dT%H:%M:%OSZ", + to = "%Y-%m-%d %H:%M:%S" +) { + format( + as.POSIXct( + timestamp, + format = from + ), + format = to + ) +} diff --git a/app/logic/job_list_utils.R b/app/logic/job_list_utils.R new file mode 100644 index 0000000..4320937 --- /dev/null +++ b/app/logic/job_list_utils.R @@ -0,0 +1,47 @@ +box::use( + shiny[ + div, + span, + icon, + a, + strong + ], + glue[glue] +) + +box::use( + app/logic/general_utils[format_timestamp] +) + + +#' Function to process each row for the job table +#' This creates the HTML for the row +#' +#' @export +process_job_data <- function( + job_data +) { + job_info <- strsplit(job_data, "_-_")[[1]] + div( + class = "job-entry", + div( + class = "job-id", + job_info[1] + ), + div( + class = "job-key", + strong("Key: "), + job_info[2] + ), + div( + class = "job-start-time", + strong("Start: "), + format_timestamp(job_info[3], "%Y-%m-%dT%H:%M:%S") + ), + div( + class = "job-end-time", + strong("End: "), + format_timestamp(job_info[4], "%Y-%m-%dT%H:%M:%S") + ) + ) +} diff --git a/app/logic/logs_utils.R b/app/logic/logs_utils.R new file mode 100644 index 0000000..314b8f2 --- /dev/null +++ b/app/logic/logs_utils.R @@ -0,0 +1,55 @@ +box::use( + glue[glue], + shiny[ + icon, + div + ] +) + +box::use( + app/logic/general_utils[check_text_error, format_timestamp] +) + +#' Function to process each row for the log table +#' This creates the HTML for the row +#' +#' @export +process_log_data <- function( + log_data +) { + log_info <- strsplit(log_data, "_-_")[[1]] + status <- get_status_info(log_info[1], log_info[3]) + div( + class = glue("log-entry {status[1]}-highlight"), + icon( + name = status[2], + class = glue( + "log-status {status[1]}-text fa-solid" + ), + ), + div( + class = "log-info-block", + div( + class = glue("log-info {status[1]}-text"), + log_info[3] + ), + div( + class = "log-time", + format_timestamp(log_info[2]) + ) + ) + ) +} + +get_status_info <- function( + output_type, + log_data +) { + if (output_type == "stdout") { + c("green", "circle-info") + } else if (output_type == "stderr" && check_text_error(log_data)) { + c("red", "circle-xmark") + } else { + c("yellow", "circle-info") + } +} diff --git a/app/main.R b/app/main.R index f310d7e..8cd1b74 100644 --- a/app/main.R +++ b/app/main.R @@ -1,25 +1,124 @@ box::use( - shiny[bootstrapPage, div, moduleServer, NS, renderUI, tags, uiOutput], + shiny[ + NS, + moduleServer, + div, + reactive, + reactiveValues, + observeEvent, + isTruthy, + tags, + tagList, + renderUI, + uiOutput, + img, + removeUI, + p, + fluidPage + ], + magrittr[`%>%`], + dplyr[select], + shinycssloaders[withSpinner] +) + +box::use( + app/view/mod_app_table, + app/view/mod_job_list, + app/view/mod_logs, + app/logic/api_utils[get_app_list] ) #' @export ui <- function(id) { ns <- NS(id) - bootstrapPage( - uiOutput(ns("message")) + fluidPage( + class = "dashboard-body", + div( + class = "app-table", + mod_app_table$ui(ns("app_table")) + ), + div( + class = "job-list", + uiOutput(ns("job_list_pane")) + ), + div( + class = "logs", + uiOutput(ns("logs_pane")) + ) ) } #' @export server <- function(id) { moduleServer(id, function(input, output, session) { - output$message <- renderUI({ - div( - style = "display: flex; justify-content: center; align-items: center; height: 100vh;", - tags$h1( - tags$a("Check out Rhino docs!", href = "https://appsilon.github.io/rhino/") - ) - ) + + ns <- session$ns + + state <- reactiveValues() + state$selected_app <- reactive({}) + state$selected_job <- reactive({}) + + app_list <- reactive({ + get_app_list() }) + + mod_app_table$server( + "app_table", + app_list() %>% + select( + guid, + name, + r_version, + dashboard_url, + last_deployed_time + ), + state + ) + + observeEvent(state$selected_app()$guid, { + if (isTruthy(state$selected_app()$guid)) { + + output$job_list_pane <- renderUI({ + mod_job_list$ui(ns("job_list")) + }) + + mod_job_list$server( + "job_list", + state + ) + } else { + removeUI(ns("job_list_pane")) + } + }, ignoreInit = TRUE, ignoreNULL = TRUE) + + observeEvent(state$selected_job()$key, { + if (isTruthy(state$selected_job()$key)) { + + output$logs_pane <- renderUI({ + mod_logs$ui(ns("logs")) + }) + + mod_logs$server( + "logs", + state + ) + } else { + output$logs_pane <- renderUI({ + div( + class = "empty-state-container", + p( + class = "empty-state-text", + "Select an application and a job to view logs" + ), + img( + src = "static/empty_state.svg", + class = "empty-state-image", + alt = "Select an application and a job to view logs" + ) + ) + }) + } + }, ignoreInit = FALSE, ignoreNULL = FALSE) + }) } diff --git a/app/static/css/app.min.css b/app/static/css/app.min.css new file mode 100644 index 0000000..4962e5a --- /dev/null +++ b/app/static/css/app.min.css @@ -0,0 +1 @@ +.red-text{color:#a50e0e}.green-text{color:#3a5a40}.yellow-text{color:#a58e0e}.red-highlight{background-color:rgba(252,232,230,.3137254902)}.green-highlight{background-color:rgba(224,240,223,.3137254902)}.yellow-highlight{background-color:rgba(240,235,187,.3137254902)}.red-text{color:#a50e0e}.green-text{color:#3a5a40}.yellow-text{color:#a58e0e}.red-highlight{background-color:rgba(252,232,230,.3137254902)}.green-highlight{background-color:rgba(224,240,223,.3137254902)}.yellow-highlight{background-color:rgba(240,235,187,.3137254902)}.logs .rt-td-inner{padding:0 !important}.logs>div{text-align:center}.logs>div .empty-state-container{margin-top:10%}.logs>div .empty-state-container .empty-state-image{width:50%}.logs>div .empty-state-container .empty-state-text{color:gray;margin-bottom:40px}.logs-container{position:relative}.logs-container .log-entry{display:flex;align-items:center;gap:20px;padding:10px;margin:5px 10px}.logs-container .log-entry i{font-size:1.5em}.logs-container .log-entry .log-info-block{display:flex;flex-direction:column;gap:10px}.logs-container .log-entry .log-info-block .log-info{font-weight:600}.logs-container .log-entry .log-info-block .log-time{font-size:.75em}.logs-container .logs-download{position:absolute;z-index:2;right:0;margin:10px;background:0;border-radius:0;padding:5px 10px}.wrapper{background:none !important}.content-wrapper{background:#fff;color:#333;height:90vh;font-family:"Segoe UI VSS (Regular)","Segoe UI",sans-serif}.dashboard-body{display:flex;flex-direction:row;padding:0;height:92.5%}.dashboard-body .Reactable{background:rgba(0,0,0,0)}.dashboard-body .rt-search{width:80%;margin:10px;align-self:center;text-align:center;border-radius:0}.dashboard-body .rt-tr-header{display:none !important}.dashboard-body .rt-tr{align-items:center}.dashboard-body .rt-tr-selected{background:rgba(0,0,0,.062745098)}.dashboard-body .app-table{background:#eaeaea;border-right:#d8d8d8 solid 1px;width:20%;height:100%;overflow-y:auto}.dashboard-body .job-list{background:#f8f8f8;border-right:#eee solid 1px;width:15%;height:100%;overflow-y:auto}.dashboard-body .logs{background:#fff;width:65%;height:100%;overflow-y:auto}.app-entry{display:flex;flex-direction:column;width:100%}.app-entry .app-title{font-size:1.1em}.app-entry .app-link-icon{font-size:.5em;margin-left:10px;margin-bottom:10px}.app-entry .app-metadata{display:flex;flex-direction:column;gap:5px;color:gray;font-size:.75em}.red-text{color:#a50e0e}.green-text{color:#3a5a40}.yellow-text{color:#a58e0e}.red-highlight{background-color:rgba(252,232,230,.3137254902)}.green-highlight{background-color:rgba(224,240,223,.3137254902)}.yellow-highlight{background-color:rgba(240,235,187,.3137254902)}.job-entry .job-key,.job-entry .job-start-time,.job-entry .job-end-time{font-size:.75em;color:gray} diff --git a/app/static/empty_state.svg b/app/static/empty_state.svg new file mode 100644 index 0000000..ff8f2cf --- /dev/null +++ b/app/static/empty_state.svg @@ -0,0 +1 @@ + diff --git a/app/styles/_app_table.scss b/app/styles/_app_table.scss new file mode 100644 index 0000000..1904be5 --- /dev/null +++ b/app/styles/_app_table.scss @@ -0,0 +1,23 @@ +.app-entry { + display: flex; + flex-direction: column; + width: 100%; + + .app-title { + font-size: 1.1em; + } + + .app-link-icon { + font-size: 0.5em; + margin-left: 10px; + margin-bottom: 10px; + } + + .app-metadata { + display: flex; + flex-direction: column; + gap: 5px; + color: $grey-text; + font-size: 0.75em; + } +} diff --git a/app/styles/_colors.scss b/app/styles/_colors.scss new file mode 100644 index 0000000..7b0e880 --- /dev/null +++ b/app/styles/_colors.scss @@ -0,0 +1,39 @@ +$red: #a50e0e; +$red-highlight: #fce8e650; +$green: #3a5a40; +$green-highlight: #e0f0df50; +$yellow: #a58e0e; +$yellow-highlight: #f0ebbb50; +$grey1: #eaeaea; +$grey1-border: #d8d8d8; +$grey2: #f8f8f8; +$grey2-border: #eee; +$black: black; +$white: white; +$grey-text: grey; +$black-text: #333; +$selected-row: #00000010; + +.red-text { + color: $red; +} + +.green-text { + color: $green; +} + +.yellow-text { + color: $yellow; +} + +.red-highlight { + background-color: $red-highlight; +} + +.green-highlight { + background-color: $green-highlight; +} + +.yellow-highlight { + background-color: $yellow-highlight; +} diff --git a/app/styles/_dashboard.scss b/app/styles/_dashboard.scss new file mode 100644 index 0000000..79674e1 --- /dev/null +++ b/app/styles/_dashboard.scss @@ -0,0 +1,64 @@ +.wrapper { + background: none !important; +} + +.content-wrapper { + background: $white; + color: $black-text; + height: 90vh; + font-family: "Segoe UI VSS (Regular)", "Segoe UI", sans-serif; +} + +.dashboard-body { + display: flex; + flex-direction: row; + padding: 0; + height: 92.5%; + + .Reactable { + background: transparent; + } + + .rt-search { + width: 80%; + margin: 10px; + align-self: center; + text-align: center; + border-radius: 0; + } + + .rt-tr-header { + display: none !important; + } + + .rt-tr { + align-items: center; + } + + .rt-tr-selected { + background: $selected-row; + } + + .app-table { + background: $grey1; + border-right: $grey1-border solid 1px; + width: 20%; + height: 100%; + overflow-y: auto; + } + + .job-list { + background: $grey2; + border-right: $grey2-border solid 1px; + width: 15%; + height: 100%; + overflow-y: auto; + } + + .logs { + background: $white; + width: 65%; + height: 100%; + overflow-y: auto; + } +} diff --git a/app/styles/_job_list.scss b/app/styles/_job_list.scss new file mode 100644 index 0000000..1ca3cef --- /dev/null +++ b/app/styles/_job_list.scss @@ -0,0 +1,10 @@ +@import "colors"; + +.job-entry { + .job-key, + .job-start-time, + .job-end-time { + font-size: 0.75em; + color: $grey-text; + } +} diff --git a/app/styles/_logs.scss b/app/styles/_logs.scss new file mode 100644 index 0000000..a414864 --- /dev/null +++ b/app/styles/_logs.scss @@ -0,0 +1,64 @@ +@import "colors"; + +.logs { + .rt-td-inner { + padding: 0 !important; + } + + > div { + text-align: center; + + .empty-state-container { + margin-top: 10%; + + .empty-state-image { + width: 50%; + } + + .empty-state-text { + color: $grey-text; + margin-bottom: 40px; + } + } + } +} + +.logs-container { + position: relative; + + .log-entry { + display: flex; + align-items: center; + gap: 20px; + padding: 10px; + margin: 5px 10px; + + i { + font-size: 1.5em; + } + + .log-info-block { + display: flex; + flex-direction: column; + gap: 10px; + + .log-info { + font-weight: 600; + } + + .log-time { + font-size: 0.75em; + } + } + } + + .logs-download { + position: absolute; + z-index: 2; + right: 0; + margin: 10px; + background: 0; + border-radius: 0; + padding: 5px 10px; + } +} diff --git a/app/styles/main.scss b/app/styles/main.scss index e69de29..847a889 100644 --- a/app/styles/main.scss +++ b/app/styles/main.scss @@ -0,0 +1,5 @@ +@import "colors"; +@import "logs"; +@import "dashboard"; +@import "app_table"; +@import "job_list"; diff --git a/app/view/__init__.R b/app/view/__init__.R deleted file mode 100644 index 65702e2..0000000 --- a/app/view/__init__.R +++ /dev/null @@ -1,2 +0,0 @@ -# View: Shiny modules and related code. -# https://go.appsilon.com/rhino-project-structure diff --git a/app/view/mod_app_table.R b/app/view/mod_app_table.R new file mode 100644 index 0000000..3f08bb8 --- /dev/null +++ b/app/view/mod_app_table.R @@ -0,0 +1,94 @@ +box::use( + shiny[ + NS, + div, + moduleServer, + reactive + ], + reactable[ + reactable, + renderReactable, + reactableOutput, + getReactableState, + colDef + ], + dplyr[ + select, + mutate + ], + magrittr[`%>%`], + glue[glue], + shinycssloaders[withSpinner] +) + +box::use( + app/logic/app_table_utils[process_app_data] +) + +#' @export +ui <- function(id) { + ns <- NS(id) + withSpinner( + reactableOutput( + ns("app_table") + ), + type = 8, + color = "#333333" + ) +} + +#' @export +server <- function(id, app_list, state) { + moduleServer(id, function(input, output, session) { + + output$app_table <- renderReactable({ + + processed_apps <- app_list %>% + mutate( + name = paste( + name, + r_version, + dashboard_url, + last_deployed_time, + sep = "_-_" + ) + ) %>% + select( + -c( + r_version, + dashboard_url, + last_deployed_time + ) + ) + + reactable( + data = processed_apps, + searchable = TRUE, + borderless = TRUE, + pagination = FALSE, + selection = "single", + columns = list( + guid = colDef( + show = FALSE + ), + name = colDef( + name = "Application", + cell = function(app_data) { + process_app_data(app_data) + } + ) + ) + ) + }) + + state$selected_app <- reactive({ + index <- getReactableState("app_table", "selected") + list( + "guid" = app_list[index, ]$guid, + "name" = app_list[index, ]$name + ) + }) + + }) + +} diff --git a/app/view/mod_job_list.R b/app/view/mod_job_list.R new file mode 100644 index 0000000..ff6700b --- /dev/null +++ b/app/view/mod_job_list.R @@ -0,0 +1,96 @@ +box::use( + shiny[ + moduleServer, + NS, + div, + reactive, + req + ], + reactable[ + reactable, + renderReactable, + reactableOutput, + getReactableState, + colDef + ], + dplyr[ + select, + mutate + ], + magrittr[`%>%`], + shinycssloaders[withSpinner] +) + +box::use( + app/logic/api_utils[get_job_list], + app/logic/job_list_utils[process_job_data] +) + +#' @export +ui <- function(id) { + ns <- NS(id) + withSpinner( + reactableOutput( + ns("job_list_table") + ), + type = 8, + color = "#333333" + ) +} + +#' @export +server <- function(id, state) { + moduleServer(id, function(input, output, session) { + + job_list_data <- reactive({ + req(state$selected_app()$guid) + get_job_list(state$selected_app()$guid) + }) + + output$job_list_table <- renderReactable({ + + processed_jobs <- job_list_data() %>% + select(id, key, start_time, end_time) %>% + mutate( + job = paste( + id, + key, + start_time, + end_time, + sep = "_-_" + ) + ) %>% + select( + -c( + id, + key, + start_time, + end_time + ) + ) + + reactable( + data = processed_jobs, + selection = "single", + borderless = TRUE, + pagination = FALSE, + columns = list( + job = colDef( + cell = function(job_data) { + process_job_data(job_data) + } + ) + ) + ) + }) + + state$selected_job <- reactive({ + index <- getReactableState("job_list_table", "selected") + list( + "key" = job_list_data()[index, ]$key, + "id" = job_list_data()[index, ]$id + ) + }) + + }) +} diff --git a/app/view/mod_logs.R b/app/view/mod_logs.R new file mode 100644 index 0000000..2803368 --- /dev/null +++ b/app/view/mod_logs.R @@ -0,0 +1,131 @@ +box::use( + shiny[ + moduleServer, + NS, + div, + reactive, + req, + downloadButton, + downloadHandler, + icon, + renderUI, + uiOutput, + observeEvent + ], + reactable[ + reactable, + renderReactable, + reactableOutput, + colDef + ], + dplyr[mutate], + magrittr[`%>%`], + glue[glue], + shinycssloaders[withSpinner] +) + +box::use( + app/logic/api_utils[get_job_logs, download_job_logs], + app/logic/logs_utils[process_log_data] +) + +#' @export +ui <- function(id) { + ns <- NS(id) + div( + class = "logs-container", + uiOutput( + ns("download_logs") + ), + withSpinner( + reactableOutput( + ns("logs_table") + ), + type = 8, + color = "#333333" + ) + ) +} + +#' @export +server <- function(id, state) { + moduleServer(id, function(input, output, session) { + + ns <- session$ns + + output$download <- downloadHandler( + filename = function() { + glue( + "{state$selected_app()$name}_{state$selected_job()$id}.txt" + ) + }, + content = function(file) { + logs <- download_job_logs( + state$selected_app()$guid, + state$selected_job()$key + ) + writeLines(logs, file) + } + ) + + observeEvent(state$selected_job()$key, { + req(state$selected_job()$key) + output$download_logs <- renderUI({ + downloadButton( + outputId = ns("download"), + label = NULL, + icon = icon("download"), + class = "logs-download" + ) + }) + }) + + logs_data <- reactive({ + req(state$selected_job()$key) + get_job_logs( + state$selected_app()$guid, + state$selected_job()$key + ) + }) + + output$logs_table <- renderReactable({ + + processed_logs <- logs_data() %>% + mutate( + log_line = paste( + entries.source, + entries.timestamp, + entries.data, + sep = "_-_" + ) + ) + + reactable( + data = processed_logs, + searchable = TRUE, + borderless = TRUE, + pagination = FALSE, + defaultSortOrder = "desc", + defaultSorted = c("entries.timestamp"), + columns = list( + entries.source = colDef( + show = FALSE + ), + entries.timestamp = colDef( + show = FALSE + ), + entries.data = colDef( + show = FALSE + ), + log_line = colDef( + name = "Logs", + cell = function(log_data) { + process_log_data(log_data) + } + ) + ) + ) + }) + + }) +} diff --git a/dependencies.R b/dependencies.R index 2c7644d..2ff2609 100644 --- a/dependencies.R +++ b/dependencies.R @@ -1,8 +1,9 @@ # This file allows packrat (used by rsconnect during deployment) to pick up dependencies. -library(rhino) -library(usethis) +library(dplyr) library(httr2) library(magrittr) library(reactable) +library(rhino) library(rsconnect) library(shinycssloaders) +library(usethis) diff --git a/renv.lock b/renv.lock index 78f1e4b..d1c946e 100644 --- a/renv.lock +++ b/renv.lock @@ -314,6 +314,29 @@ ], "Hash": "b18a9cf3c003977b0cc49d5e76ebe48d" }, + "dplyr": { + "Package": "dplyr", + "Version": "1.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "generics", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "rlang", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "fedd9d00c2944ff00a0e2696ccf048ec" + }, "ellipsis": { "Package": "ellipsis", "Version": "0.3.2", @@ -378,6 +401,17 @@ ], "Hash": "47b5f30c720c23999b913a1a635cf0bb" }, + "generics": { + "Package": "generics", + "Version": "0.1.3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods" + ], + "Hash": "15e9634c0fcd294799e9b2e929ed1b86" + }, "gert": { "Package": "gert", "Version": "2.0.1", @@ -1165,6 +1199,22 @@ ], "Hash": "a84e2cc86d07289b3b6f5069df7a004c" }, + "tidyselect": { + "Package": "tidyselect", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang", + "vctrs", + "withr" + ], + "Hash": "79540e5fcd9e0435af547d885f184fd5" + }, "tinytex": { "Package": "tinytex", "Version": "0.49",