diff --git a/lib/cortex_status_web/components/layouts/root.html.heex b/lib/cortex_status_web/components/layouts/root.html.heex index 6cb01a8..38eb4b4 100644 --- a/lib/cortex_status_web/components/layouts/root.html.heex +++ b/lib/cortex_status_web/components/layouts/root.html.heex @@ -1467,6 +1467,199 @@ .engram-session-row { cursor: pointer; } + + /* Mission Control page */ + .mc-page { + max-width: 860px; + margin: 0 auto; + padding: 2rem 1.5rem; + } + + .mc-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + } + + .mc-header h1 { + font-size: 1.6rem; + font-weight: 600; + letter-spacing: -0.02em; + } + + .mc-subtitle { + display: block; + color: var(--text-muted); + font-size: 0.85rem; + margin-top: 0.2rem; + } + + .mc-back-link { + color: var(--text-muted); + font-size: 0.85rem; + padding-top: 0.4rem; + } + + .mc-stats-bar { + display: flex; + gap: 2.5rem; + margin-bottom: 1.5rem; + padding: 1rem 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + } + + .mc-stat { + display: flex; + flex-direction: column; + align-items: center; + } + + .mc-stat-num { + font-size: 1.4rem; + font-weight: 700; + font-family: var(--mono); + color: var(--blue); + } + + .mc-stat-num.mc-active { + color: var(--green); + animation: pulse 2s ease-in-out infinite; + } + + .mc-stat-lbl { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .mc-filters { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; + } + + .mc-filter-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.8rem; + padding: 0.35rem 0.8rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + font-family: var(--mono); + } + + .mc-filter-btn:hover { + border-color: var(--blue); + color: var(--text); + } + + .mc-filter-btn.active { + background: var(--blue-bg); + border-color: var(--blue); + color: var(--blue); + } + + .mc-filter-count { + font-size: 0.7rem; + opacity: 0.7; + margin-left: 0.25rem; + } + + .mc-loading { + text-align: center; + padding: 3rem; + color: var(--text-muted); + } + + .mc-session-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .mc-session-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.25rem; + cursor: pointer; + transition: all 0.15s; + } + + .mc-session-card:hover { + background: var(--surface-hover); + border-color: var(--blue); + transform: translateY(-1px); + } + + .mc-card-top { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .mc-type-badge { + font-size: 0.65rem; + font-family: var(--mono); + padding: 0.12rem 0.45rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .mc-type-cowork { color: var(--blue); background: var(--blue-bg); } + .mc-type-code { color: var(--green); background: var(--green-bg); } + .mc-type-desktop { color: var(--yellow); background: var(--yellow-bg); } + .mc-type-api { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); } + .mc-type-other, .mc-type-test { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); } + + .mc-status-pill { + font-size: 0.65rem; + font-family: var(--mono); + padding: 0.12rem 0.45rem; + border-radius: 4px; + text-transform: uppercase; + } + + .mc-st-completed { color: var(--green); background: var(--green-bg); } + .mc-st-active { color: var(--blue); background: var(--blue-bg); } + .mc-st-unknown { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); } + + .mc-card-date { + margin-left: auto; + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--mono); + } + + .mc-card-summary { + font-size: 0.9rem; + line-height: 1.5; + color: var(--text); + } + + .mc-card-outcome { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.4rem; + padding-top: 0.4rem; + border-top: 1px solid var(--border); + line-height: 1.4; + } + + .mc-empty { + text-align: center; + padding: 3rem; + color: var(--text-muted); + } diff --git a/lib/cortex_status_web/live/task_live.ex b/lib/cortex_status_web/live/task_live.ex index d4dc9c6..6a39d02 100644 --- a/lib/cortex_status_web/live/task_live.ex +++ b/lib/cortex_status_web/live/task_live.ex @@ -1,13 +1,12 @@ defmodule CortexStatusWeb.TaskLive do @moduledoc """ - Mission Control LiveView for Symbiont compound task orchestration. - Provides real-time visualization of task decomposition, subtask routing, - execution progress, and costs. + Mission Control — session history browser powered by Engram. + Shows all recorded sessions with clickable detail views including + activity log timelines. """ use CortexStatusWeb, :live_view - @poll_interval 1_000 - @auth_token "cortex-tasks-2026" + @http_timeout 5_000 defp symbiont_url do config = Application.get_env(:cortex_status, :services, []) @@ -16,544 +15,352 @@ defmodule CortexStatusWeb.TaskLive do @impl true def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status") + send(self(), :load_sessions) + end + {:ok, assign(socket, page_title: "Mission Control", - authenticated: false, - password_input: "", - auth_error: nil, - prompt: "", - active_task: nil, - task_history: [], - submitting: false, - submit_error: nil, - poll_timer: nil + sessions: [], + stats: %{}, + selected_session: nil, + session_logs: [], + modal_loading: false, + filter: "all", + loading: true )} end @impl true - def handle_event("authenticate", %{"password" => password}, socket) do - if password == @auth_token do - # Load recent tasks on auth - task_history = fetch_recent_tasks() - - {:noreply, - assign(socket, - authenticated: true, - password_input: "", - auth_error: nil, - task_history: task_history - )} - else - {:noreply, assign(socket, auth_error: "Invalid password", password_input: "")} - end + def handle_info(:load_sessions, socket) do + {stats, sessions} = fetch_engram_data() + {:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)} end - def handle_event("update_prompt", %{"prompt" => prompt}, socket) do - {:noreply, assign(socket, prompt: prompt)} + def handle_info({:status_update, new_status}, socket) do + # Piggyback on the monitor's engram data to stay fresh + engram = new_status.engram + stats = engram.stats + sessions = engram.sessions + + {:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)} end - def handle_event("submit_task", %{"prompt" => prompt}, socket) do - if String.trim(prompt) == "" do - {:noreply, socket} - else - socket = assign(socket, submitting: true, submit_error: nil) - case submit_compound_task(prompt) do - {:ok, task_data} -> - # The POST response only has {id, status, subtask_count}. - # Build a placeholder with empty subtasks until first poll fills it in. - placeholder = %{ - "id" => task_data["id"], - "prompt" => String.trim(prompt), - "status" => task_data["status"] || "planned", - "reasoning" => nil, - "subtasks" => [], - "created_at" => DateTime.to_iso8601(DateTime.utc_now()), - "completed_at" => nil, - "total_cost" => 0.0 - } + def handle_info({:fetch_logs, session_id}, socket) do + logs = + case Req.get("#{symbiont_url()}/engram/sessions/#{session_id}/logs?limit=200", + receive_timeout: @http_timeout + ) do + {:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) -> + Enum.reverse(logs) - timer = Process.send_after(self(), :poll_task, 200) - - {:noreply, - assign(socket, - submitting: false, - active_task: placeholder, - prompt: "", - submit_error: nil, - poll_timer: timer - )} - - {:error, reason} -> - {:noreply, - assign(socket, - submitting: false, - active_task: nil, - submit_error: "Failed to submit task: #{inspect(reason)}" - )} + _ -> + [] end - end - end - def handle_event("logout", _, socket) do - if socket.assigns.poll_timer do - Process.cancel_timer(socket.assigns.poll_timer) - end - - {:noreply, - assign(socket, - authenticated: false, - password_input: "", - active_task: nil, - prompt: "", - task_history: [], - poll_timer: nil - )} + {:noreply, assign(socket, session_logs: logs, modal_loading: false)} end @impl true - def handle_info(:poll_task, socket) do - case socket.assigns.active_task do - nil -> - {:noreply, socket} + def handle_event("select_session", %{"id" => session_id}, socket) do + session = Enum.find(socket.assigns.sessions, fn s -> gv(s, "id") == session_id end) - task -> - case fetch_task_progress(task["id"]) do - {:ok, updated_task} when is_map(updated_task) -> - status = updated_task["status"] || "unknown" - terminal = status in ["completed", "failed", "partial"] - - timer = - if terminal do - # Refresh history when task finishes - nil - else - Process.send_after(self(), :poll_task, @poll_interval) - end - - {:noreply, - assign(socket, - active_task: updated_task, - poll_timer: timer - )} - - _ -> - # API error or malformed response — keep polling but don't crash - timer = Process.send_after(self(), :poll_task, @poll_interval) - {:noreply, assign(socket, poll_timer: timer)} - end + if session do + 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 + + def handle_event("filter", %{"type" => type}, socket) do + {:noreply, assign(socket, filter: type)} + end + @impl true def render(assigns) do + # Apply filter + filtered = + assigns.sessions + |> Enum.reject(fn s -> gv(s, "session_type") == "test" end) + |> then(fn sessions -> + case assigns.filter do + "all" -> sessions + type -> Enum.filter(sessions, fn s -> gv(s, "session_type") == type end) + end + end) + |> Enum.sort_by(fn s -> gv(s, "started_at", "") end, :desc) + + # Compute type counts for filter badges + all_real = + assigns.sessions + |> Enum.reject(fn s -> gv(s, "session_type") == "test" end) + + type_counts = + all_real + |> Enum.frequencies_by(fn s -> gv(s, "session_type", "other") end) + + assigns = + assigns + |> assign(:filtered, filtered) + |> assign(:type_counts, type_counts) + |> assign(:total_real, length(all_real)) + ~H""" -
- <%= if !@authenticated do %> - <.auth_gate auth_error={@auth_error} /> +
+
+
+

Mission Control

+ Engram Session History +
+ ← Status +
+ +
+
+ <%= gv(@stats, "total_sessions", 0) %> + Sessions +
+
+ <%= gv(@stats, "total_logs", 0) %> + Log entries +
+
+ 0, do: "mc-active", else: ""}"}> + <%= gv(@stats, "active_sessions", 0) %> + + Active now +
+
+ +
+ + <%= for {type, count} <- Enum.sort(@type_counts) do %> + + <% end %> +
+ + <%= if @loading do %> +
Loading sessions...
<% else %> - <.task_interface - prompt={@prompt} - submitting={@submitting} - active_task={@active_task} - task_history={@task_history} - submit_error={@submit_error} +
+ <%= for session <- @filtered do %> +
+
+ <%= gv(session, "session_type", "?") %> + <%= gv(session, "status", "?") %> + <%= fmt_date(gv(session, "started_at", "")) %> +
+
<%= gv(session, "summary", "\u2014") %>
+ <%= if gv(session, "completion_summary", nil) do %> +
<%= gv(session, "completion_summary", "") %>
+ <% end %> +
+ <% end %> + <%= if @filtered == [] do %> +
No sessions match this filter.
+ <% end %> +
+ <% end %> + + <%= if @selected_session do %> + <.session_modal + session={@selected_session} + logs={@session_logs} + loading={@modal_loading} /> <% end %>
""" end - attr :auth_error, :string, default: nil + # ── 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 + + assigns = + assigns + |> assign(:sid, gv(session, "id", "")) + |> assign(:stype, gv(session, "session_type", "?")) + |> assign(:status_val, gv(session, "status", "unknown")) + |> assign(:summary, gv(session, "summary", "\u2014")) + |> assign(:started, gv(session, "started_at", "")) + |> assign(:completed, gv(session, "completed_at", nil)) + |> assign(:completion_summary, gv(session, "completion_summary", nil)) - defp auth_gate(assigns) do ~H""" -
-
-

Mission Control

-

Symbiont Task Orchestration

- - <%= if @auth_error do %> -
- <%= @auth_error %> +