diff --git a/docker-compose.yaml b/docker-compose.yaml index 7b5f49f..68b7559 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,22 +11,3 @@ services: volumes: - ./:/app - ./config.yaml:/app/config.yaml:ro - melesicu_shiny: - build: ./shiny-app - container_name: melesicu_shiny - restart: unless-stopped - environment: - PG_DSN: ${PG_DSN} - S3_BUCKET: ${S3_BUCKET} - ENTRANCE_PREFIX: "icu/entrance/" - PROCESSED_PREFIX: "icu/processed/" - THUMB_PREFIX: "icu/thumbnails/" - INVENTORY_TABLE: "cam_inventory" - DEPLOYMENT_TABLE: "cam_deployments" - AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} - AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} - AWS_DEFAULT_REGION: "us-east-1" - AWS_S3_ENDPOINT: ${S3_ENDPOINT} - AWS_S3_ENDPOINT_URL: ${S3_ENDPOINT} - ports: - - "3838:3838" diff --git a/shiny-app/Dockerfile b/shiny-app/Dockerfile deleted file mode 100644 index caab08f..0000000 --- a/shiny-app/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM rocker/shiny:latest - -RUN apt-get update && apt-get install -y --no-install-recommends \ - libpq-dev \ - libcurl4-openssl-dev \ - libssl-dev \ - libxml2-dev \ - curl \ - unzip \ - && rm -rf /var/lib/apt/lists/* - -RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \ - && unzip awscliv2.zip \ - && ./aws/install \ - && rm -rf awscliv2.zip aws - -RUN R -e "install.packages(c('DBI','RPostgres','dplyr','DT','glue','shiny'), repos='https://cloud.r-project.org')" - -WORKDIR /srv/shiny-server -COPY app.R /srv/shiny-server/app.R - -EXPOSE 3838 diff --git a/shiny-app/app.R b/shiny-app/app.R deleted file mode 100644 index 5cd5433..0000000 --- a/shiny-app/app.R +++ /dev/null @@ -1,517 +0,0 @@ -library(shiny) -library(DBI) -library(RPostgres) -library(dplyr) -library(DT) -library(glue) - -pg_dsn <- Sys.getenv("PG_DSN", "") -s3_bucket <- Sys.getenv("S3_BUCKET", "") -s3_endpoint <- Sys.getenv("S3_ENDPOINT", Sys.getenv("AWS_S3_ENDPOINT", Sys.getenv("AWS_S3_ENDPOINT_URL", ""))) -aws_access_key <- Sys.getenv("S3_ACCESS_KEY", Sys.getenv("AWS_ACCESS_KEY_ID", "")) -aws_secret_key <- Sys.getenv("S3_SECRET_KEY", Sys.getenv("AWS_SECRET_ACCESS_KEY", "")) -aws_cli <- Sys.getenv("AWS_CLI", "aws") -thumb_prefix <- Sys.getenv("THUMB_PREFIX", "icu/thumbnails/") -processed_prefix <- Sys.getenv("PROCESSED_PREFIX", "icu/processed/") -entrance_prefix <- Sys.getenv("ENTRANCE_PREFIX", "icu/entrance/") -processed_prefix <- Sys.getenv("PROCESSED_PREFIX", "icu/processed/") -thumb_prefix <- Sys.getenv("THUMB_PREFIX", "icu/thumbnails/") - -inventory_table <- Sys.getenv("INVENTORY_TABLE", "") -deployment_table <- Sys.getenv("DEPLOYMENT_TABLE", "") - -if (pg_dsn == "") { - stop("PG_DSN env var is required") -} -if (s3_bucket == "") { - stop("S3_BUCKET env var is required") -} -if (s3_endpoint == "") { - stop("S3_ENDPOINT/AWS_S3_ENDPOINT env var is required") -} -if (aws_access_key == "" || aws_secret_key == "") { - stop("S3_ACCESS_KEY/S3_SECRET_KEY or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY required") -} - -Sys.setenv( - AWS_ACCESS_KEY_ID = aws_access_key, - AWS_SECRET_ACCESS_KEY = aws_secret_key, - AWS_EC2_METADATA_DISABLED = "true" -) - -parse_pg_dsn <- function(dsn) { - if (grepl("^postgresql://", dsn)) { - m <- regexec("^postgresql://([^:]+):([^@]+)@([^:/]+):?(\\d+)?/(.+)$", dsn) - r <- regmatches(dsn, m)[[1]] - if (length(r) == 0) stop("Invalid PG_DSN URL format") - return(list( - user = r[2], - password = r[3], - host = r[4], - port = ifelse(r[5] == "", 5432, as.integer(r[5])), - dbname = r[6] - )) - } - - if (grepl("host=", dsn)) { - parts <- strsplit(dsn, "\\s+")[[1]] - kv <- lapply(parts, function(p) strsplit(p, "=", fixed = TRUE)[[1]]) - vals <- setNames(lapply(kv, `[`, 2), lapply(kv, `[`, 1)) - return(list( - user = vals$user, - password = vals$password, - host = vals$host, - port = as.integer(vals$port), - dbname = vals$dbname - )) - } - - return(list(dbname = dsn)) -} - -pg <- parse_pg_dsn(pg_dsn) -pg_con <- do.call(dbConnect, c(list(RPostgres::Postgres()), pg)) -onStop(function() dbDisconnect(pg_con)) - -table_exists <- function(con, table_name) { - res <- dbGetQuery(con, "SELECT to_regclass($1) AS t", params = list(table_name)) - !is.na(res$t[1]) -} - -fetch_cameras <- function() { - if (inventory_table != "" && table_exists(pg_con, inventory_table)) { - q <- glue("SELECT DISTINCT cam_id FROM {inventory_table} ORDER BY 1") - return(dbGetQuery(pg_con, q)$cam_id) - } - character(0) -} - -fetch_projects <- function() { - if (deployment_table != "" && table_exists(pg_con, deployment_table)) { - q <- glue("SELECT DISTINCT project FROM {deployment_table} ORDER BY 1") - return(dbGetQuery(pg_con, q)$project) - } - character(0) -} - -build_query <- function(from_date, to_date, camera_id, project_name, limit_rows, offset_rows, use_date_filter) { - base <- " - SELECT - m.metadata_uuid, - m.import_ts, - m.ocr_ts, - m.\"CustomerTimezone\", - m.\"SignalStrength\", - m.\"Temperature\", - m.imei, - m.servertime, - ci.cam_id, - d.project, - c.category, - c.score - FROM remote_cam.metadata m - LEFT JOIN cam_inventory ci ON ci.imei = m.imei - LEFT JOIN LATERAL ( - SELECT d.project - FROM cam_deployments d - WHERE d.camera_id = ci.cam_id - AND m.servertime >= d.deployment_start - AND (d.deployment_end IS NULL OR m.servertime < d.deployment_end) - ORDER BY d.deployment_start DESC NULLS LAST - LIMIT 1 - ) d ON TRUE - LEFT JOIN LATERAL ( - SELECT category, score - FROM remote_cam.category c - WHERE c.image_uuid = m.metadata_uuid - ORDER BY score DESC NULLS LAST - LIMIT 1 - ) c ON TRUE - " - - params <- list() - - if (camera_id != "" && inventory_table != "" && table_exists(pg_con, inventory_table)) { - base <- paste0(base, " WHERE ci.cam_id = $", length(params) + 1) - params <- append(params, list(camera_id)) - } - - if (project_name != "" && deployment_table != "" && table_exists(pg_con, deployment_table)) { - prefix <- if (length(params) == 0) " WHERE " else " AND " - base <- paste0(base, prefix, " m.imei IN (SELECT ci2.imei FROM cam_inventory ci2 JOIN ", - deployment_table, " d ON d.camera_id = ci2.cam_id WHERE d.project = $", - length(params) + 1, ")") - params <- append(params, list(project_name)) - } - - if (camera_id == "" && project_name == "" && !use_date_filter) { - base <- paste0(base, " ORDER BY m.import_ts DESC LIMIT $", length(params) + 1, " OFFSET $", length(params) + 2) - params <- append(params, list(as.integer(limit_rows), as.integer(offset_rows))) - return(list(sql = base, params = params)) - } - - if (use_date_filter) { - prefix <- if (length(params) == 0) " WHERE " else " AND " - base <- paste0(base, prefix, " m.import_ts >= $", length(params) + 1, " AND m.import_ts < $", length(params) + 2) - params <- append(params, list(from_date, to_date)) - } - - base <- paste0(base, " ORDER BY m.import_ts DESC LIMIT $", length(params) + 1, " OFFSET $", length(params) + 2) - params <- append(params, list(as.integer(limit_rows), as.integer(offset_rows))) - list(sql = base, params = params) -} - -normalize_endpoint <- function(url) { - if (url == "") return("") - u <- url - if (!grepl("^https?://", u)) { - u <- paste0("https://", u) - } - sub("/+$", "", u) -} - -s3_endpoint <- normalize_endpoint(s3_endpoint) - -if (aws_access_key == "" || aws_secret_key == "" || s3_endpoint == "") { - stop("S3_ACCESS_KEY/S3_SECRET_KEY or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY and S3_ENDPOINT are required") -} - -normalize_endpoint <- function(url) { - if (url == "") return("") - u <- url - if (!grepl("^https?://", u)) { - u <- paste0("https://", u) - } - sub("/+$", "", u) -} - -s3_endpoint <- normalize_endpoint(s3_endpoint) -cache_dir <- Sys.getenv("THUMB_CACHE_DIR", "/tmp/thumb_cache") -dir.create(cache_dir, recursive = TRUE, showWarnings = FALSE) -addResourcePath("thumbs", cache_dir) - -presigned_url <- function(key, expires = 600) { - if (is.null(key) || is.na(key) || key == "") return(NULL) - key <- sub(paste0("^", s3_bucket, "/"), "", key) - cmd <- sprintf( - "\"%s\" --endpoint-url %s s3 presign s3://%s/%s --expires-in %d", - aws_cli, s3_endpoint, s3_bucket, key, as.integer(expires) - ) - tryCatch({ - out <- system(cmd, intern = TRUE) - if (length(out) == 0) return(NULL) - out[1] - }, error = function(e) { - message(sprintf("[DEBUG] presign error: %s", e$message)) - NULL - }) -} - -cache_thumbnail <- function(uuid) { - if (is.null(uuid) || is.na(uuid)) return(NULL) - local_path <- file.path(cache_dir, paste0(uuid, ".jpg")) - if (file.exists(local_path)) return(local_path) - key <- paste0(thumb_prefix, uuid, ".jpg") - cmd <- sprintf( - "\"%s\" --endpoint-url %s s3 cp s3://%s/%s \"%s\" --only-show-errors", - aws_cli, s3_endpoint, s3_bucket, key, local_path - ) - tryCatch({ - system(cmd, ignore.stdout = TRUE, ignore.stderr = TRUE) - if (file.exists(local_path)) return(local_path) - NULL - }, error = function(e) { - message(sprintf("[DEBUG] cache thumb error: %s", e$message)) - NULL - }) -} - -ui <- fluidPage( - titlePanel("MelesICU - Image Browser"), - tags$script(HTML(" - function melesThumbClick(el){ - var uuid = el.getAttribute('data-uuid'); - if(uuid){ - Shiny.setInputValue('thumb_click', uuid, {priority: 'event'}); - } - } - var melesScrollLock = false; - window.addEventListener('scroll', function() { - if (melesScrollLock) return; - var nearBottom = (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 200); - var canLoad = Shiny && Shiny.shinyapp && Shiny.shinyapp.$outputValues && Shiny.shinyapp.$outputValues.has_more_flag; - var isLoading = Shiny && Shiny.shinyapp && Shiny.shinyapp.$outputValues && Shiny.shinyapp.$outputValues.loading_flag; - if (nearBottom && canLoad && !isLoading) { - melesScrollLock = true; - Shiny.setInputValue('scroll_load', Date.now(), {priority: 'event'}); - setTimeout(function(){ melesScrollLock = false; }, 1000); - } - }); - ")), - sidebarLayout( - sidebarPanel( - dateRangeInput("date_range", "Datum", start = Sys.Date() - 7, end = Sys.Date()), - checkboxInput("use_date_filter", "Datumsfilter aktiv", value = TRUE), - selectInput("camera_id", "Kamera (cam_id)", choices = c("ALL", fetch_cameras()), selected = "ALL"), - selectInput("project_name", "Projekt", choices = c("ALL", fetch_projects()), selected = "ALL"), - numericInput("limit_rows", "Eintraege pro Seite", value = 20, min = 10, max = 2000, step = 10), - numericInput("page", "Seite", value = 1, min = 1, step = 1), - fluidRow( - column(6, actionButton("prev_page", "Zurueck")), - column(6, actionButton("next_page", "Vor")) - ), - actionButton("apply_filters", "Filter anwenden") - ), - mainPanel( - tags$style(HTML(" - .spinner { - margin: 20px 0; - width: 32px; - height: 32px; - border: 4px solid #ddd; - border-top-color: #333; - border-radius: 50%; - animation: spin 1s linear infinite; - } - @keyframes spin { to { transform: rotate(360deg); } } - ")), - conditionalPanel("output.loading_flag", tags$div(class = "spinner")), - uiOutput("debug_info"), - uiOutput("thumb_grid") - ) - ) -) - -server <- function(input, output, session) { - rv <- reactiveValues(data = NULL, selected = NULL, loading = FALSE, has_more = TRUE, debug = NULL) - cache_env <- new.env(parent = emptyenv()) - cache_keys <- character(0) - url_cache_env <- new.env(parent = emptyenv()) - url_cache_keys <- character(0) - - load_data <- function() { - from_date <- as.POSIXct(input$date_range[1], tz = "UTC") - to_date <- as.POSIXct(input$date_range[2] + 1, tz = "UTC") - cam <- ifelse(input$camera_id == "ALL", "", input$camera_id) - proj <- ifelse(input$project_name == "ALL", "", input$project_name) - page <- max(1, as.integer(input$page)) - limit <- as.integer(input$limit_rows) - offset <- (page - 1) * limit - - key <- paste(from_date, to_date, cam, proj, input$use_date_filter, limit, offset, sep = "|") - if (exists(key, envir = cache_env, inherits = FALSE)) { - rv$data <- get(key, envir = cache_env, inherits = FALSE) - return(invisible(NULL)) - } - - q <- build_query(from_date, to_date, cam, proj, limit, offset, input$use_date_filter) - rv$loading <- TRUE - on.exit({ rv$loading <- FALSE }, add = TRUE) - data <- dbGetQuery(pg_con, q$sql, params = q$params) - assign(key, data, envir = cache_env) - cache_keys <<- c(cache_keys, key) - if (length(cache_keys) > 20) { - drop <- head(cache_keys, length(cache_keys) - 20) - for (k in drop) rm(list = k, envir = cache_env) - cache_keys <<- tail(cache_keys, 20) - } - rv$data <- data - rv$has_more <- nrow(data) >= limit - - # Batch prefetch first 10 thumbnails in parallel - if (!is.null(data) && nrow(data) > 0) { - uuids <- head(data$metadata_uuid, 10) - tryCatch({ - parallel::mclapply(uuids, cache_thumbnail, mc.cores = 4) - }, error = function(e) { - message(sprintf("[DEBUG] prefetch error: %s", e$message)) - }) - } - - if (!is.null(data) && nrow(data) > 0) { - sample_uuid <- data$metadata_uuid[1] - thumb_key <- paste0(thumb_prefix, sample_uuid, ".jpg") - url <- presigned_url(thumb_key) - rv$debug <- list( - count = nrow(data), - sample_uuid = sample_uuid, - thumb_key = thumb_key, - url = url - ) - message(sprintf("[DEBUG] count=%d sample_uuid=%s thumb_key=%s url=%s", - nrow(data), sample_uuid, thumb_key, ifelse(is.null(url), "NULL", url))) - } else { - rv$debug <- list(count = 0) - message("[DEBUG] count=0") - } - } - - observeEvent(input$apply_filters, { - if (rv$loading) { - showNotification("Noch am Laden – bitte warten.", type = "message") - return(NULL) - } - load_data() - }, ignoreInit = TRUE) - - observeEvent(input$prev_page, { - if (rv$loading) return(NULL) - new_page <- max(1, as.integer(input$page) - 1) - updateNumericInput(session, "page", value = new_page) - load_data() - }, ignoreInit = TRUE) - - observeEvent(input$next_page, { - if (rv$loading) return(NULL) - new_page <- as.integer(input$page) + 1 - updateNumericInput(session, "page", value = new_page) - load_data() - }, ignoreInit = TRUE) - - observeEvent(input$scroll_load, { - if (rv$loading || !rv$has_more) return(NULL) - new_page <- as.integer(input$page) + 1 - updateNumericInput(session, "page", value = new_page) - load_data() - }, ignoreInit = TRUE) - - observeEvent(TRUE, { - if (rv$loading) return(NULL) - load_data() - }, once = TRUE) - - output$loading_flag <- reactive({ rv$loading }) - outputOptions(output, "loading_flag", suspendWhenHidden = FALSE) - - output$has_more_flag <- reactive({ rv$has_more }) - outputOptions(output, "has_more_flag", suspendWhenHidden = FALSE) - - format_cet <- function(ts) { - if (is.null(ts) || is.na(ts)) return("-") - format(as.POSIXct(ts, tz = "CET"), "%Y-%m-%d %H:%M:%S %Z") - } - - format_ts <- function(ts) { - if (is.null(ts) || is.na(ts)) return("-") - format(as.POSIXct(ts, tz = "CET"), "%Y-%m-%d %H:%M:%S %Z") - } - - format_diff <- function(server_ts, camera_ts) { - if (is.null(server_ts) || is.na(server_ts) || is.null(camera_ts) || is.na(camera_ts)) return("-") - secs <- as.numeric(difftime(server_ts, camera_ts, units = "secs")) - sign <- ifelse(secs >= 0, "+", "-") - secs <- abs(secs) - hh <- floor(secs / 3600) - mm <- floor((secs %% 3600) / 60) - ss <- floor(secs %% 60) - sprintf("%s%02d:%02d:%02d", sign, hh, mm, ss) - } - - output$thumb_grid <- renderUI({ - dat <- rv$data - if (is.null(dat) || nrow(dat) == 0) { - return(tags$div("Keine Ergebnisse.")) - } - - cards <- lapply(seq_len(nrow(dat)), function(i) { - row <- dat[i, ] - local_path <- cache_thumbnail(row$metadata_uuid) - if (is.null(local_path)) return(NULL) - url <- paste0("thumbs/", basename(local_path)) - tags$div( - style = "display:inline-block; width: 220px; margin: 6px; vertical-align: top;", - tags$div(class = "ph", style = "width: 220px; height: 220px; background:#eee; display:block;"), - tags$img( - src = url, - style = "width: 220px; height: 220px; object-fit: cover; display:block; cursor: pointer;", - loading = "lazy", - onload = "this.parentNode.querySelector('.ph').style.display='none';", - `data-uuid` = row$metadata_uuid, - onclick = "melesThumbClick(this)" - ), - tags$div(style = "font-size: 12px; color: #222;", - glue("CamID: {row$cam_id}")), - tags$div(style = "font-size: 12px; color: #444;", - glue("Serverzeit: {format_cet(row$servertime)}")), - tags$div(style = "font-size: 12px; color: #444;", - glue("Kamerazeit: {format_ts(row$ocr_ts)}")), - tags$div(style = "font-size: 12px; color: #444;", - glue("AI classification: {row$category} ({row$score})")), - { - delta <- format_diff(row$servertime, row$ocr_ts) - delta_secs <- if (delta == "-") NA else as.numeric(difftime(row$servertime, row$ocr_ts, units = "secs")) - is_warn <- !is.na(delta_secs) && abs(delta_secs) > 600 - style <- if (is_warn) "font-size: 12px; color: #c00; font-weight: bold;" else "font-size: 12px; color: #444;" - tags$div(style = style, glue("Delta: {delta}")) - } - ) - }) - - tags$div(cards) - }) - - output$debug_info <- renderUI({ - if (is.null(rv$debug)) return(NULL) - cnt <- rv$debug$count - if (is.null(cnt)) return(NULL) - if (cnt == 0) { - return(tags$div(style = "font-size:12px; color:#a00; margin-bottom:8px;", - "Debug: 0 Treffer.")) - } - tags$div(style = "font-size:12px; color:#555; margin-bottom:8px;", - glue("Debug: Treffer={cnt}, Sample UUID={rv$debug$sample_uuid}"), - tags$br(), - glue("Thumb-Key: {rv$debug$thumb_key}"), - tags$br(), - if (!is.null(rv$debug$url)) tags$a(href = rv$debug$url, "Sample Thumbnail (Link)", target = "_blank") else "Sample URL: NULL") - }) - - observeEvent(input$thumb_click, { - uuid <- input$thumb_click - dat <- rv$data - if (is.null(dat)) return(NULL) - row <- dat[dat$metadata_uuid == uuid, ] - if (nrow(row) != 1) return(NULL) - full_key <- paste0(processed_prefix, row$metadata_uuid, ".jpg") - presigned_cached <- function(key, expires = 600) { - now <- Sys.time() - if (exists(key, envir = url_cache_env, inherits = FALSE)) { - entry <- get(key, envir = url_cache_env, inherits = FALSE) - if (is.list(entry) && !is.null(entry$expires_at) && now < entry$expires_at) { - return(entry$url) - } - } - u <- presigned_url(key, expires = expires) - if (!is.null(u)) { - assign(key, list(url = u, expires_at = now + expires - 30), envir = url_cache_env) - url_cache_keys <<- c(url_cache_keys, key) - if (length(url_cache_keys) > 200) { - drop <- head(url_cache_keys, length(url_cache_keys) - 200) - for (k in drop) rm(list = k, envir = url_cache_env) - url_cache_keys <<- tail(url_cache_keys, 200) - } - } - u - } - - url <- presigned_cached(full_key) - if (is.null(url)) { - showModal(modalDialog( - title = glue("Bild {uuid}"), - "Full image nicht gefunden.", - easyClose = TRUE, - footer = NULL - )) - return(NULL) - } - showModal(modalDialog( - title = glue("Bild {uuid}"), - tags$img(src = url, style = "max-width: 100%; height: auto;"), - easyClose = TRUE, - footer = modalButton("Schließen"), - size = "l" - )) - }) -} - -shinyApp(ui, server)