Add clickable session detail modal with activity log timeline
This commit is contained in:
parent
824bf31f0c
commit
93a0e0ab78
@ -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;
|
||||
}
|
||||
</style>
|
||||
<script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script>
|
||||
</head>
|
||||
|
||||
@ -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 %>
|
||||
|
||||
<footer class="status-footer">
|
||||
<p>Checks run every 15 seconds •
|
||||
<a href="/dashboard">LiveDashboard</a>
|
||||
@ -113,7 +164,6 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
stats = assigns.engram.stats
|
||||
sessions = assigns.engram.sessions
|
||||
|
||||
# Filter out test sessions and sort by started_at desc
|
||||
real_sessions =
|
||||
sessions
|
||||
|> Enum.reject(fn s -> get_val(s, "session_type", "") == "test" end)
|
||||
@ -156,11 +206,15 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
|
||||
<div class="engram-sessions">
|
||||
<%= 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", ""))}"}>
|
||||
<%= get_val(session, "session_type", "?") %>
|
||||
</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>
|
||||
</div>
|
||||
<% end %>
|
||||
@ -172,6 +226,93 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
"""
|
||||
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">×</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 --
|
||||
|
||||
defp overall_status(status) do
|
||||
@ -214,7 +355,7 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
[
|
||||
{"CPU", "#{data[:cpu_load_1min]}%"},
|
||||
{"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"},
|
||||
{"Uptime", data[:uptime_human] || "—"}
|
||||
{"Uptime", data[:uptime_human] || "\u2014"}
|
||||
]
|
||||
end
|
||||
|
||||
@ -228,7 +369,7 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
[
|
||||
{"Queue", "#{get_val(data, "queue_size", 0)} tasks"},
|
||||
{"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
|
||||
|
||||
@ -261,7 +402,7 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
:ok -> "#{latency}ms"
|
||||
:degraded -> "HTTP #{http_code}"
|
||||
:error -> to_string(error || "down")
|
||||
_ -> "—"
|
||||
_ -> "\u2014"
|
||||
end
|
||||
|
||||
{name, status_str}
|
||||
@ -276,7 +417,7 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
|
||||
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
|
||||
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||
@ -289,12 +430,11 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
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
|
||||
# Handle both "2026-03-23T12:00:00" and "2026-03-23T12:00:00Z"
|
||||
case DateTime.from_iso8601(iso_str) do
|
||||
{:ok, dt, _} -> format_relative_date(dt)
|
||||
_ ->
|
||||
@ -305,7 +445,7 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
end
|
||||
end
|
||||
|
||||
defp format_session_date(_), do: "—"
|
||||
defp format_session_date(_), do: "\u2014"
|
||||
|
||||
defp format_relative_date(dt) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||
@ -315,8 +455,38 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
days == 0 -> "today"
|
||||
days == 1 -> "yesterday"
|
||||
days < 7 -> "#{days}d ago"
|
||||
true ->
|
||||
Calendar.strftime(dt, "%b %d")
|
||||
true -> Calendar.strftime(dt, "%b %d")
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user