From 93a0e0ab784d6f163abcb04b48cc0e8a2a3a5347 Mon Sep 17 00:00:00 2001 From: Muse Date: Mon, 23 Mar 2026 13:10:19 +0000 Subject: [PATCH] Add clickable session detail modal with activity log timeline --- .../components/layouts/root.html.heex | 231 ++++++++++++++++++ lib/cortex_status_web/live/status_live.ex | 198 +++++++++++++-- 2 files changed, 415 insertions(+), 14 deletions(-) diff --git a/lib/cortex_status_web/components/layouts/root.html.heex b/lib/cortex_status_web/components/layouts/root.html.heex index 2a43f0a..6cb01a8 100644 --- a/lib/cortex_status_web/components/layouts/root.html.heex +++ b/lib/cortex_status_web/components/layouts/root.html.heex @@ -1236,6 +1236,237 @@ text-align: center; color: var(--text-muted); } + + /* Session detail modal */ + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 2rem; + animation: fadeIn 0.15s ease-out; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + .modal-content { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + width: 100%; + max-width: 640px; + max-height: 80vh; + display: flex; + flex-direction: column; + animation: slideUp 0.2s ease-out; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); + } + + @keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + + .modal-title-row { + display: flex; + align-items: center; + gap: 0.6rem; + } + + .modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 6px; + line-height: 1; + transition: all 0.15s; + } + + .modal-close:hover { + color: var(--text); + background: var(--surface-hover); + } + + .modal-status-badge { + font-size: 0.7rem; + font-family: var(--mono); + padding: 0.15rem 0.5rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .modal-status-completed { color: var(--green); background: var(--green-bg); } + .modal-status-active { color: var(--blue); background: var(--blue-bg); } + .modal-status-unknown { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); } + + .modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; + } + + .modal-summary { + font-size: 1.1rem; + font-weight: 500; + line-height: 1.5; + margin-bottom: 1.25rem; + } + + .modal-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem 2rem; + margin-bottom: 1.25rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); + } + + .meta-item { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + .meta-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .meta-value { + font-size: 0.85rem; + color: var(--text); + } + + .meta-value.mono { + font-family: var(--mono); + font-size: 0.8rem; + } + + .modal-completion { + margin-bottom: 1.25rem; + padding: 1rem; + background: var(--green-bg); + border: 1px solid rgba(52, 211, 153, 0.2); + border-radius: 8px; + } + + .completion-label { + font-size: 0.7rem; + color: var(--green); + text-transform: uppercase; + letter-spacing: 0.05em; + display: block; + margin-bottom: 0.4rem; + } + + .completion-text { + font-size: 0.9rem; + line-height: 1.5; + color: var(--text); + } + + .modal-logs-section { + margin-top: 0.5rem; + } + + .logs-header { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .logs-count { + font-size: 0.75rem; + font-weight: 400; + color: var(--text-muted); + font-family: var(--mono); + } + + .logs-loading { + padding: 1.5rem; + text-align: center; + color: var(--text-muted); + font-style: italic; + } + + .logs-empty { + padding: 1.5rem; + text-align: center; + color: var(--text-muted); + } + + .logs-timeline { + display: flex; + flex-direction: column; + gap: 0; + border-left: 2px solid var(--border); + margin-left: 0.5rem; + padding-left: 1rem; + } + + .log-entry { + display: flex; + gap: 0.75rem; + padding: 0.5rem 0; + position: relative; + } + + .log-entry::before { + content: ''; + position: absolute; + left: -1.35rem; + top: 0.85rem; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border); + border: 2px solid var(--surface); + } + + .log-time { + font-size: 0.75rem; + font-family: var(--mono); + color: var(--text-muted); + flex-shrink: 0; + min-width: 3rem; + padding-top: 0.1rem; + } + + .log-text { + font-size: 0.85rem; + line-height: 1.5; + color: var(--text); + } + + /* Make session rows clickable */ + .engram-session-row { + cursor: pointer; + } diff --git a/lib/cortex_status_web/live/status_live.ex b/lib/cortex_status_web/live/status_live.ex index d40a107..66e248b 100644 --- a/lib/cortex_status_web/live/status_live.ex +++ b/lib/cortex_status_web/live/status_live.ex @@ -13,7 +13,14 @@ defmodule CortexStatusWeb.StatusLive do status = CortexStatus.Services.Monitor.get_status() - {:ok, assign(socket, status: status, page_title: "Cortex Status")} + {:ok, + assign(socket, + status: status, + page_title: "Cortex Status", + selected_session: nil, + session_logs: [], + modal_loading: false + )} end @impl true @@ -21,6 +28,42 @@ defmodule CortexStatusWeb.StatusLive do {:noreply, assign(socket, status: new_status)} end + @impl true + def handle_event("select_session", %{"id" => session_id}, socket) do + # Find the session in our cached data + sessions = socket.assigns.status.engram.sessions + session = Enum.find(sessions, fn s -> get_val(s, "id", nil) == session_id end) + + if session do + # Fetch logs async + send(self(), {:fetch_logs, session_id}) + {:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)} + else + {:noreply, socket} + end + end + + def handle_event("close_modal", _params, socket) do + {:noreply, assign(socket, selected_session: nil, session_logs: [], modal_loading: false)} + end + + @impl true + def handle_info({:fetch_logs, session_id}, socket) do + config = Application.get_env(:cortex_status, :services, []) + url = Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111") + + logs = + case Req.get("#{url}/engram/sessions/#{session_id}/logs?limit=100", receive_timeout: 5_000) do + {:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) -> logs + _ -> [] + end + + # Sort logs oldest-first (they come newest-first from the API) + logs = Enum.reverse(logs) + + {:noreply, assign(socket, session_logs: logs, modal_loading: false)} + end + @impl true def render(assigns) do ~H""" @@ -62,6 +105,14 @@ defmodule CortexStatusWeb.StatusLive do <.engram_section engram={@status.engram} /> + <%= if @selected_session do %> + <.session_modal + session={@selected_session} + logs={@session_logs} + loading={@modal_loading} + /> + <% end %> +