Add clickable session detail modal with activity log timeline

This commit is contained in:
Muse 2026-03-23 13:10:19 +00:00
parent 824bf31f0c
commit 93a0e0ab78
2 changed files with 415 additions and 14 deletions

View File

@ -1236,6 +1236,237 @@
text-align: center; text-align: center;
color: var(--text-muted); 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;
}
</style> </style>
<script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script> <script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script>
</head> </head>

View File

@ -13,7 +13,14 @@ defmodule CortexStatusWeb.StatusLive do
status = CortexStatus.Services.Monitor.get_status() 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 end
@impl true @impl true
@ -21,6 +28,42 @@ defmodule CortexStatusWeb.StatusLive do
{:noreply, assign(socket, status: new_status)} {:noreply, assign(socket, status: new_status)}
end 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -62,6 +105,14 @@ defmodule CortexStatusWeb.StatusLive do
<.engram_section engram={@status.engram} /> <.engram_section engram={@status.engram} />
<%= if @selected_session do %>
<.session_modal
session={@selected_session}
logs={@session_logs}
loading={@modal_loading}
/>
<% end %>
<footer class="status-footer"> <footer class="status-footer">
<p>Checks run every 15 seconds &bull; <p>Checks run every 15 seconds &bull;
<a href="/dashboard">LiveDashboard</a> <a href="/dashboard">LiveDashboard</a>
@ -113,7 +164,6 @@ defmodule CortexStatusWeb.StatusLive do
stats = assigns.engram.stats stats = assigns.engram.stats
sessions = assigns.engram.sessions sessions = assigns.engram.sessions
# Filter out test sessions and sort by started_at desc
real_sessions = real_sessions =
sessions sessions
|> Enum.reject(fn s -> get_val(s, "session_type", "") == "test" end) |> Enum.reject(fn s -> get_val(s, "session_type", "") == "test" end)
@ -156,11 +206,15 @@ defmodule CortexStatusWeb.StatusLive do
<div class="engram-sessions"> <div class="engram-sessions">
<%= for session <- @real_sessions do %> <%= for session <- @real_sessions do %>
<div class="engram-session-row"> <div
class="engram-session-row"
phx-click="select_session"
phx-value-id={get_val(session, "id", "")}
>
<span class={"session-type-badge #{session_type_class(get_val(session, "session_type", ""))}"}> <span class={"session-type-badge #{session_type_class(get_val(session, "session_type", ""))}"}>
<%= get_val(session, "session_type", "?") %> <%= get_val(session, "session_type", "?") %>
</span> </span>
<span class="session-summary"><%= get_val(session, "summary", "") %></span> <span class="session-summary"><%= get_val(session, "summary", "\u2014") %></span>
<span class="session-date"><%= format_session_date(get_val(session, "started_at", "")) %></span> <span class="session-date"><%= format_session_date(get_val(session, "started_at", "")) %></span>
</div> </div>
<% end %> <% end %>
@ -172,6 +226,93 @@ defmodule CortexStatusWeb.StatusLive do
""" """
end end
# ── Session Detail Modal ──
attr :session, :map, required: true
attr :logs, :list, required: true
attr :loading, :boolean, default: false
defp session_modal(assigns) do
session = assigns.session
status = get_val(session, "status", "unknown")
stype = get_val(session, "session_type", "?")
assigns =
assigns
|> assign(:status_val, status)
|> assign(:stype, stype)
|> assign(:summary, get_val(session, "summary", "\u2014"))
|> assign(:started, get_val(session, "started_at", ""))
|> assign(:completed, get_val(session, "completed_at", nil))
|> assign(:completion_summary, get_val(session, "completion_summary", nil))
|> assign(:session_id, get_val(session, "id", ""))
~H"""
<div class="modal-backdrop" phx-click="close_modal">
<div class="modal-content" phx-click-away="close_modal">
<div class="modal-header">
<div class="modal-title-row">
<span class={"session-type-badge #{session_type_class(@stype)}"}><%= @stype %></span>
<span class={"modal-status-badge modal-status-#{@status_val}"}><%= @status_val %></span>
</div>
<button class="modal-close" phx-click="close_modal">&times;</button>
</div>
<div class="modal-body">
<h3 class="modal-summary"><%= @summary %></h3>
<div class="modal-meta">
<div class="meta-item">
<span class="meta-label">Session ID</span>
<span class="meta-value mono"><%= @session_id %></span>
</div>
<div class="meta-item">
<span class="meta-label">Started</span>
<span class="meta-value"><%= format_full_date(@started) %></span>
</div>
<%= if @completed do %>
<div class="meta-item">
<span class="meta-label">Completed</span>
<span class="meta-value"><%= format_full_date(@completed) %></span>
</div>
<% end %>
</div>
<%= if @completion_summary do %>
<div class="modal-completion">
<span class="completion-label">Outcome</span>
<p class="completion-text"><%= @completion_summary %></p>
</div>
<% end %>
<div class="modal-logs-section">
<h4 class="logs-header">
Activity Log
<span class="logs-count"><%= length(@logs) %> entries</span>
</h4>
<%= if @loading do %>
<div class="logs-loading">Loading...</div>
<% else %>
<%= if @logs == [] do %>
<p class="logs-empty">No log entries recorded for this session.</p>
<% else %>
<div class="logs-timeline">
<%= for log <- @logs do %>
<div class="log-entry">
<span class="log-time"><%= format_log_time(get_val(log, "timestamp", "")) %></span>
<span class="log-text"><%= get_val(log, "entry", "") %></span>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
"""
end
# -- Helpers -- # -- Helpers --
defp overall_status(status) do defp overall_status(status) do
@ -214,7 +355,7 @@ defmodule CortexStatusWeb.StatusLive do
[ [
{"CPU", "#{data[:cpu_load_1min]}%"}, {"CPU", "#{data[:cpu_load_1min]}%"},
{"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"}, {"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"},
{"Uptime", data[:uptime_human] || ""} {"Uptime", data[:uptime_human] || "\u2014"}
] ]
end end
@ -228,7 +369,7 @@ defmodule CortexStatusWeb.StatusLive do
[ [
{"Queue", "#{get_val(data, "queue_size", 0)} tasks"}, {"Queue", "#{get_val(data, "queue_size", 0)} tasks"},
{"Rate Limited", if(get_val(data, "rate_limited", false), do: "Yes", else: "No")}, {"Rate Limited", if(get_val(data, "rate_limited", false), do: "Yes", else: "No")},
{"Last Heartbeat", get_val(data, "last_heartbeat", "")} {"Last Heartbeat", get_val(data, "last_heartbeat", "\u2014")}
] ]
end end
@ -261,7 +402,7 @@ defmodule CortexStatusWeb.StatusLive do
:ok -> "#{latency}ms" :ok -> "#{latency}ms"
:degraded -> "HTTP #{http_code}" :degraded -> "HTTP #{http_code}"
:error -> to_string(error || "down") :error -> to_string(error || "down")
_ -> "" _ -> "\u2014"
end end
{name, status_str} {name, status_str}
@ -276,7 +417,7 @@ defmodule CortexStatusWeb.StatusLive do
defp get_val(_not_map, _key, default), do: default defp get_val(_not_map, _key, default), do: default
defp format_ago(nil), do: "" defp format_ago(nil), do: "\u2014"
defp format_ago(%DateTime{} = dt) do defp format_ago(%DateTime{} = dt) do
diff = DateTime.diff(DateTime.utc_now(), dt, :second) diff = DateTime.diff(DateTime.utc_now(), dt, :second)
@ -289,12 +430,11 @@ defmodule CortexStatusWeb.StatusLive do
end end
end end
defp format_ago(_), do: "" defp format_ago(_), do: "\u2014"
defp format_session_date(""), do: "" defp format_session_date(""), do: "\u2014"
defp format_session_date(iso_str) when is_binary(iso_str) do defp format_session_date(iso_str) when is_binary(iso_str) do
# Handle both "2026-03-23T12:00:00" and "2026-03-23T12:00:00Z"
case DateTime.from_iso8601(iso_str) do case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> format_relative_date(dt) {:ok, dt, _} -> format_relative_date(dt)
_ -> _ ->
@ -305,7 +445,7 @@ defmodule CortexStatusWeb.StatusLive do
end end
end end
defp format_session_date(_), do: "" defp format_session_date(_), do: "\u2014"
defp format_relative_date(dt) do defp format_relative_date(dt) do
diff = DateTime.diff(DateTime.utc_now(), dt, :second) diff = DateTime.diff(DateTime.utc_now(), dt, :second)
@ -315,8 +455,38 @@ defmodule CortexStatusWeb.StatusLive do
days == 0 -> "today" days == 0 -> "today"
days == 1 -> "yesterday" days == 1 -> "yesterday"
days < 7 -> "#{days}d ago" days < 7 -> "#{days}d ago"
true -> true -> Calendar.strftime(dt, "%b %d")
Calendar.strftime(dt, "%b %d")
end end
end end
defp format_full_date(""), do: "\u2014"
defp format_full_date(nil), do: "\u2014"
defp format_full_date(iso_str) when is_binary(iso_str) do
case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> Calendar.strftime(dt, "%b %d, %Y at %H:%M UTC")
_ ->
case NaiveDateTime.from_iso8601(iso_str) do
{:ok, ndt} -> Calendar.strftime(ndt, "%b %d, %Y at %H:%M")
_ -> iso_str
end
end
end
defp format_full_date(_), do: "\u2014"
defp format_log_time(""), do: ""
defp format_log_time(iso_str) when is_binary(iso_str) do
case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> Calendar.strftime(dt, "%H:%M")
_ ->
case NaiveDateTime.from_iso8601(iso_str) do
{:ok, ndt} -> Calendar.strftime(ndt, "%H:%M")
_ -> ""
end
end
end
defp format_log_time(_), do: ""
end end