Compare commits

..

No commits in common. "3594b3edd8914085afb3bc9caed15939a4767610" and "51f741965f244b100cf8d1edc612b58e2f5acad8" have entirely different histories.

10 changed files with 33 additions and 2752 deletions

View File

@ -19,7 +19,6 @@ config :cortex_status, :services,
dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
monitored_sites: [ monitored_sites: [
{"hydrascale.net", "https://hydrascale.net"}, {"hydrascale.net", "https://hydrascale.net"},
{"git.hydrascale.net", "https://git.hydrascale.net"},
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"} {"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
] ]

View File

@ -12,22 +12,22 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """
host = System.get_env("PHX_HOST") || "cortex.hydrascale.net" host = System.get_env("PHX_HOST") || "status.hydrascale.net"
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
config :cortex_status, CortexStatusWeb.Endpoint, config :cortex_status, CortexStatusWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ip: {127, 0, 0, 1}, port: port], http: [ip: {127, 0, 0, 1}, port: port],
secret_key_base: secret_key_base, secret_key_base: secret_key_base
check_origin: ["https://cortex.hydrascale.net", "https://status.hydrascale.net"]
# Override service URLs in prod if env vars are set
config :cortex_status, :services, config :cortex_status, :services,
symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111", symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111",
dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000", dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000",
dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
monitored_sites: [ monitored_sites: [
{"hydrascale.net", "https://hydrascale.net"}, {"hydrascale.net", "https://hydrascale.net"},
{"git.hydrascale.net", "https://git.hydrascale.net"}, {"browser.hydrascale.net", "https://browser.hydrascale.net/health"},
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"} {"cortex.hydrascale.net", "https://cortex.hydrascale.net"}
] ]
end end

View File

@ -1,473 +0,0 @@
# Elixir / Phoenix Learnings — Cortex Status Dashboard
Patterns, gotchas, and reference notes from building the cortex_status Phoenix app.
## Project: cortex_status
- **Location on cortex**: `/data/cortex_status/`
- **Service**: `symbiont-ex-api.service` (systemd)
- **Port**: 4000 (behind Caddy at status.hydrascale.net)
- **Framework**: Phoenix 1.7.14, LiveView 1.0.0, LiveDashboard 0.8.4
---
## LiveView Patterns
### PubSub for Real-Time Updates
The status page subscribes to PubSub topics on mount and receives broadcast updates:
```elixir
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
end
{:ok, assign(socket, ...)}
end
def handle_info({:status_update, new_status}, socket) do
{:noreply, assign(socket, status: new_status)}
end
```
### Polling Pattern (Process.send_after)
For polling an external API from a LiveView (e.g., task progress):
```elixir
@poll_interval 1_000
# Start polling
timer = Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, poll_timer: timer)}
# Handle poll
def handle_info(:poll_task, socket) do
case fetch_progress(socket.assigns.task_id) do
{:ok, data} when is_map(data) ->
terminal = data["status"] in ["completed", "failed"]
timer = if terminal, do: nil, else: Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, data: data, poll_timer: timer)}
_ ->
# Don't crash on bad data — keep polling
timer = Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, poll_timer: timer)}
end
end
```
**Gotcha**: Always cancel timers on unmount/logout:
```elixir
if socket.assigns.poll_timer, do: Process.cancel_timer(socket.assigns.poll_timer)
```
### Component Attrs and Passing Assigns
When defining function components with `attr`, use `assigns` directly. For passing whole assigns bundles to sub-components, use a named assign:
```elixir
# Don't try to pass @assigns directly — use a named prop
<.auth_gate socket_assigns={assigns} />
defp auth_gate(assigns) do
~H"""
<%= @socket_assigns.prompt %>
"""
end
```
### phx-change Goes on the Form, Not Individual Inputs
```elixir
# WRONG: phx-change on textarea alone won't fire
<textarea phx-change="update_prompt" name="prompt"></textarea>
# RIGHT: phx-change on the form, inputs trigger it
<form phx-submit="submit" phx-change="update">
<textarea name="prompt"><%= @prompt %></textarea>
</form>
```
### Enum.with_index Returns {element, index}
```elixir
# WRONG — destructuring is backwards:
for {index, element} <- Enum.with_index(list)
# RIGHT:
for {element, index} <- Enum.with_index(list)
```
---
## LiveDashboard Custom Pages
### Basic Structure
```elixir
defmodule MyApp.DashboardPages.MyPage do
use Phoenix.LiveDashboard.PageBuilder
@impl true
def menu_link(_, _), do: {:ok, "Page Title"}
@impl true
def render_page(_assigns) do
{:ok, row(components: [card(value: "Hello", inner_title: "Title")])}
end
end
```
### Registration in Router
```elixir
live_dashboard "/dashboard",
metrics: MyAppWeb.Telemetry,
additional_pages: [
my_page: MyApp.DashboardPages.MyPage
]
```
### Available Components
- `card(value:, inner_title:)` — simple KV display
- `table(columns:, rows:, id:, title:, row_attrs:)` — data table
- `row(components:)` — horizontal layout
- `columns(columns:)` — multi-column layout
**Gotcha**: `row_attrs` must be a function: `fn row -> [{"data-id", row.id}] end`
---
## DateTime Gotchas
### DateTime.from_iso8601 Returns {:error, reason}, Not :error
```elixir
# WRONG:
case DateTime.from_iso8601(str) do
{:ok, dt, _} -> dt
:error -> str # This clause never matches!
end
# RIGHT:
case DateTime.from_iso8601(str) do
{:ok, dt, _} -> dt
{:error, _} -> str # Correct error tuple
end
```
---
## HTTP Calls from LiveView/GenServer
### Using Req Library
```elixir
# GET
case Req.get("http://localhost:8111/status", receive_timeout: 5000) do
{:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body}
{:ok, %{status: code}} -> {:error, "HTTP #{code}"}
{:error, reason} -> {:error, reason}
end
# POST with JSON
case Req.post(url, json: %{"prompt" => prompt}) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
...
end
```
**Gotcha**: Always handle when `body` might be a string (not auto-parsed JSON). Req parses JSON automatically when content-type is application/json.
---
## Application Configuration
### Reading Config at Runtime (Not Compile-Time)
Use function calls instead of module attributes for config that may change:
```elixir
# WRONG — baked in at compile time:
@symbiont_url Application.get_env(:cortex_status, :services)[:symbiont_url]
# RIGHT — reads at runtime:
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
end
```
---
## os_mon for System Metrics
The app uses Erlang's `os_mon` for host-level metrics (requires `:os_mon` in `extra_applications`):
```elixir
cpu_load = :cpu_sup.avg1() / 256 # normalized 0-1
{mem_total, mem_alloc, _} = :memsup.get_memory_data()
disk_data = :disksup.get_disk_data() # [{mount, total_kb, percent_used}]
```
---
## Release & Deployment
### Build Commands
```bash
cd /data/cortex_status
MIX_ENV=prod mix deps.get
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy # tailwind + esbuild
MIX_ENV=prod mix release --overwrite
systemctl restart symbiont-ex-api
```
### Environment Variables (runtime.exs)
- `SECRET_KEY_BASE` — required in prod
- `PHX_HOST` — defaults to cortex.hydrascale.net
- `PORT` — defaults to 4000
- `SYMBIONT_URL` — override Symbiont API base URL
---
## check_origin: The LiveView Connection Killer
When LiveView connections silently fail (`liveSocket.isConnected()` returns `false`,
`_mount_attempts` climbs into the thousands), the most likely culprit is a `check_origin`
mismatch. Phoenix checks the HTTP `Origin` header against its configured URL.
**Symptom**: Phoenix logs show:
```
[error] Could not check origin for Phoenix.Socket transport.
Origin of the request: https://cortex.hydrascale.net
```
**Root cause**: Setting `url: [host: h, port: 443, scheme: "https"]` in `runtime.exs`
causes Phoenix to expect an origin of `https://cortex.hydrascale.net:443`, but browsers
send `https://cortex.hydrascale.net` (no port — 443 is implicit for HTTPS). String
comparison fails.
**Fix**: Explicitly set `check_origin` in the endpoint config in `runtime.exs`:
```elixir
config :cortex_status, CortexStatusWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {127, 0, 0, 1}, port: port],
secret_key_base: secret_key_base,
check_origin: ["https://cortex.hydrascale.net"] # ← explicit, no port
```
**Note**: LiveView uses `longpoll` as its transport (WebSocket upgrades may not work
through all Caddy configs). `longpoll` is functionally identical for most purposes —
slightly more latency but fully supported.
---
## LiveView Auth Gate Pattern
### Password-Protected LiveView Pages
For pages that need a simple password gate (like Mission Control), use assigns to track
auth state and conditionally render:
```elixir
def mount(_params, _session, socket) do
{:ok, assign(socket, authenticated: false, error: nil, prompt: "")}
end
def handle_event("authenticate", %{"password" => pw}, socket) do
if pw == Application.get_env(:my_app, :task_password) do
{:noreply, assign(socket, authenticated: true, error: nil)}
else
{:noreply, assign(socket, error: "Invalid password")}
end
end
```
In the template, wrap content with `<%= if @authenticated do %>`.
**Gotcha**: The password check happens server-side in the LiveView process, so it's
secure even though the HTML is rendered client-side. But remember: the initial static
render (before WebSocket connects) will show the unauthenticated state, so don't put
sensitive data in the assigns until after authentication.
---
## LiveView Silent Failures — Always Show Error Feedback
### The Problem
When a LiveView event handler hits an error (API call fails, validation error, etc.)
and you just return `{:noreply, socket}` without updating assigns, the user sees
*nothing happen*. The button appears to do nothing. This is extremely confusing.
### The Fix Pattern
Always maintain an `error` assign and display it:
```elixir
def handle_event("submit_task", %{"prompt" => prompt}, socket) do
case submit_to_api(prompt) do
{:ok, task_id} ->
{:noreply, assign(socket, task_id: task_id, error: nil)}
{:error, reason} ->
{:noreply, assign(socket, error: "Task failed: #{reason}")}
end
end
```
In the template:
```elixir
<%= if @error do %>
<div class="alert alert-danger"><%= @error %></div>
<% end %>
```
**Lesson learned the hard way**: The Mission Control "Execute" button did nothing
for a while because the Symbiont API was rejecting the auth token, but the error
was swallowed silently. Always surface errors to the UI.
---
## API Authentication from LiveView
### Bearer Token vs Query Param vs JSON Body
When calling external APIs from LiveView, be careful about where auth tokens go.
FastAPI's `Depends()` for auth reads from specific locations — if the API expects
a query param and you send it in the JSON body, auth silently fails.
**Preferred pattern**: Use `Authorization: Bearer <token>` header — it's unambiguous:
```elixir
headers = [{"authorization", "Bearer #{token}"}, {"content-type", "application/json"}]
case Req.post(url, json: payload, headers: headers) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: code, body: body}} -> {:error, "HTTP #{code}: #{inspect(body)}"}
{:error, reason} -> {:error, inspect(reason)}
end
```
**Gotcha**: Always match on the status code, not just `{:ok, _}`. A 401 or 500
response is still `{:ok, %Req.Response{}}` — it's only `:error` if the HTTP
request itself fails (timeout, DNS, connection refused).
---
## LiveView Form Gotchas (Expanded)
### phx-submit Doesn't Fire Without a Submit Button
If your form has `phx-submit="do_thing"` but no `<button type="submit">`, pressing
Enter in a text input may not trigger the event in all browsers.
### Textarea Value Persistence
When using `phx-change` on a form with a textarea, the server receives the current
value on every keystroke. If your `handle_event` for `phx-change` doesn't re-assign
the textarea value, it can appear to reset or flicker:
```elixir
# In handle_event("update", params, socket):
def handle_event("update", %{"prompt" => prompt}, socket) do
{:noreply, assign(socket, prompt: prompt)} # ← must re-assign
end
```
### Form Params Are Always Strings
All form params arrive as strings, even for number inputs:
```elixir
# WRONG:
def handle_event("set_count", %{"count" => count}, socket) when is_integer(count)
# This clause NEVER matches — count is always a string
# RIGHT:
def handle_event("set_count", %{"count" => count_str}, socket) do
count = String.to_integer(count_str)
{:noreply, assign(socket, count: count)}
end
```
---
## LiveDashboard Gotchas
### table() Component Limitations
The LiveDashboard `table()` component expects very specific data shapes and can be
finicky with dynamic data. If your data doesn't fit cleanly, use `card()` with
formatted text instead — it's more flexible and less error-prone.
**What went wrong**: Mission Control initially tried to use `table()` for task display
but hit issues with dynamic columns. Switched to `card()` with pre-formatted text,
which worked immediately.
### Custom Page render_page/1 Returns Tuples
`render_page/1` must return `{:ok, component_tree}`, not just a component:
```elixir
# WRONG:
def render_page(assigns), do: row(components: [...])
# RIGHT:
def render_page(_assigns), do: {:ok, row(components: [...])}
```
---
## Debugging LiveView Connections
### Diagnosis Checklist (when LiveView "doesn't work")
1. **Check `liveSocket.isConnected()`** in browser console — `false` means the
WebSocket/longpoll connection failed
2. **Check `_mount_attempts`** — if climbing into thousands, it's retrying and failing
3. **Check Phoenix logs** for `check_origin` errors (most common cause)
4. **Check Caddy/reverse proxy** — WebSocket upgrade headers may be stripped
5. **Check `runtime.exs`** — host/port/scheme must match the actual public URL
6. **Use Dendrite** to automate this: navigate to the page, run
`liveSocket.isConnected()` via JS, check the result programmatically
### Longpoll vs WebSocket
Phoenix LiveView supports both transports. Behind Caddy, longpoll is often more
reliable. In `app.js`:
```javascript
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
// transport: WebSocket // uncomment to force WebSocket
})
```
If WebSocket connections fail silently, LiveView falls back to longpoll automatically.
This is fine for most use cases.
---
## Caddy + Phoenix Integration Notes
### Reverse Proxy Config
```
cortex.hydrascale.net {
reverse_proxy localhost:4000
encode gzip
}
```
**Important**: Caddy handles TLS termination. Phoenix should listen on plain HTTP
(127.0.0.1 only). Don't configure Phoenix for HTTPS — let Caddy do it.
### The Self-Check Trap
If your Phoenix status page monitors URLs including its own domain
(e.g., `cortex.hydrascale.net`), the HTTP request goes through Caddy, back to
Phoenix, creating a circular dependency that times out. The timeout handler may
also lose the site name, producing mysterious "?" entries.
**Fix**: Don't have the app check itself. If the status page is loading, it's up.
---
## Release Build Gotchas
### Mix Release vs Mix Run
In development: `mix phx.server` or `iex -S mix phx.server`
In production: always use a release build:
```bash
MIX_ENV=prod mix deps.get
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release --overwrite
```
**Gotcha**: `mix assets.deploy` must run BEFORE `mix release`. The release bundles
the compiled assets — if you skip this step, the release will serve stale CSS/JS
or no assets at all.
### Config Hierarchy Matters
```
config/config.exs → compile-time defaults (all envs)
config/dev.exs → compile-time dev overrides
config/prod.exs → compile-time prod overrides
config/runtime.exs → runtime config (reads env vars, runs at boot)
```
**Critical**: `Application.get_env/3` in module attributes (`@foo Application.get_env(...)`)
reads at **compile time**. Use `Application.compile_env/3` to make this explicit, or
better yet, read config in a function that runs at runtime.
### SECRET_KEY_BASE
Required in prod. Generate with: `mix phx.gen.secret`
Set as environment variable or hardcode in runtime.exs (on a single-server deploy
where the .env file is secured, this is fine).

View File

@ -34,8 +34,7 @@ defmodule CortexStatus.Services.Monitor do
server: %{status: :unknown, data: %{}, checked_at: nil}, server: %{status: :unknown, data: %{}, checked_at: nil},
symbiont: %{status: :unknown, data: %{}, checked_at: nil}, symbiont: %{status: :unknown, data: %{}, checked_at: nil},
dendrite: %{status: :unknown, data: %{}, checked_at: nil}, dendrite: %{status: :unknown, data: %{}, checked_at: nil},
websites: %{status: :unknown, data: %{}, checked_at: nil}, websites: %{status: :unknown, data: %{}, checked_at: nil}
engram: %{stats: %{}, sessions: [], checked_at: nil}
} }
# Do first poll after a short delay to let the app boot # Do first poll after a short delay to let the app boot
@ -72,8 +71,7 @@ defmodule CortexStatus.Services.Monitor do
Task.async(fn -> {:server, check_server()} end), Task.async(fn -> {:server, check_server()} end),
Task.async(fn -> {:symbiont, check_symbiont()} end), Task.async(fn -> {:symbiont, check_symbiont()} end),
Task.async(fn -> {:dendrite, check_dendrite()} end), Task.async(fn -> {:dendrite, check_dendrite()} end),
Task.async(fn -> {:websites, check_websites()} end), Task.async(fn -> {:websites, check_websites()} end)
Task.async(fn -> {:engram, check_engram()} end)
] ]
results = results =
@ -88,12 +86,8 @@ defmodule CortexStatus.Services.Monitor do
end) end)
Enum.reduce(results, state, fn Enum.reduce(results, state, fn
{:engram, {stats, sessions}}, acc ->
Map.put(acc, :engram, %{stats: stats, sessions: sessions, checked_at: now})
{service, {status, data}}, acc -> {service, {status, data}}, acc ->
Map.put(acc, service, %{status: status, data: data, checked_at: now}) Map.put(acc, service, %{status: status, data: data, checked_at: now})
_, acc -> _, acc ->
acc acc
end) end)
@ -101,10 +95,12 @@ defmodule CortexStatus.Services.Monitor do
defp check_server do defp check_server do
try do try do
cpu_load = :cpu_sup.avg1() / 256 # Use os_mon for local BEAM host metrics
cpu_load = :cpu_sup.avg1() / 256 # normalized to 0-1
{mem_total, mem_alloc, _} = :memsup.get_memory_data() {mem_total, mem_alloc, _} = :memsup.get_memory_data()
disk_data = :disksup.get_disk_data() disk_data = :disksup.get_disk_data()
# Also grab system uptime
{uptime_str, 0} = System.cmd("cat", ["/proc/uptime"]) {uptime_str, 0} = System.cmd("cat", ["/proc/uptime"])
uptime_seconds = uptime_str |> String.split() |> hd() |> String.to_float() |> trunc() uptime_seconds = uptime_str |> String.split() |> hd() |> String.to_float() |> trunc()
@ -223,34 +219,6 @@ defmodule CortexStatus.Services.Monitor do
{:error, %{"error" => Exception.message(e)}} {:error, %{"error" => Exception.message(e)}}
end end
defp check_engram do
config = Application.get_env(:cortex_status, :services, [])
url = Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
stats_task = Task.async(fn ->
case Req.get("#{url}/engram/stats", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: body}} when is_map(body) -> body
_ -> %{}
end
end)
sessions_task = Task.async(fn ->
case Req.get("#{url}/engram/sessions/recent?hours=8760", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: %{"sessions" => sessions}}} when is_list(sessions) -> sessions
_ -> []
end
end)
stats = Task.await(stats_task, @http_timeout + 1_000)
sessions = Task.await(sessions_task, @http_timeout + 1_000)
{stats, sessions}
rescue
e ->
Logger.warning("Engram check failed: #{inspect(e)}")
{%{}, []}
end
# -- Helpers -- # -- Helpers --
defp format_disk_data(disk_data) do defp format_disk_data(disk_data) do

File diff suppressed because it is too large Load Diff

View File

@ -1,81 +0,0 @@
defmodule CortexStatusWeb.DashboardPages.TasksPage do
@moduledoc """
Custom LiveDashboard page for Symbiont compound task monitoring.
Shows recent tasks, their decomposition, and execution progress.
"""
use Phoenix.LiveDashboard.PageBuilder
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
end
@impl true
def menu_link(_, _) do
{:ok, "Tasks"}
end
@impl true
def render_page(_assigns) do
recent_tasks = fetch_recent_tasks()
tasks_data =
Enum.map(recent_tasks, fn task ->
%{
id: task["id"] || "unknown",
prompt: String.slice(task["prompt"] || "", 0..80),
status: String.upcase(task["status"] || "unknown"),
subtasks: "#{task["completed_count"] || 0}/#{task["subtask_count"] || 0}",
cost: "$#{format_cost(task["total_cost"] || 0.0)}",
created: task["created_at"] || ""
}
end)
if Enum.empty?(tasks_data) do
{:ok,
row(
components: [
card(value: "No compound tasks submitted yet", inner_title: "Tasks")
]
)}
else
{:ok,
row(
components: [
card(
value: format_tasks_table(tasks_data),
inner_title: "Recent Compound Tasks"
)
]
)}
end
end
defp fetch_recent_tasks do
case Req.get("#{symbiont_url()}/tasks/recent") do
{:ok, %{status: 200, body: tasks}} when is_list(tasks) -> tasks
_ -> []
end
end
defp format_cost(cost) when is_number(cost) do
:io_lib.format("~.4f", [cost]) |> to_string()
end
defp format_cost(_), do: "0.0000"
defp format_tasks_table(tasks_data) do
if Enum.empty?(tasks_data) do
"No tasks"
else
task_lines =
Enum.map(tasks_data, fn task ->
"#{task.id} | #{task.prompt} | #{task.status} | #{task.subtasks} | #{task.cost} | #{task.created}"
end)
header = "Task ID | Prompt | Status | Progress | Cost | Created\n"
separator = String.duplicate("", 80) <> "\n"
header <> separator <> Enum.join(task_lines, "\n")
end
end
end

View File

@ -13,14 +13,7 @@ defmodule CortexStatusWeb.StatusLive do
status = CortexStatus.Services.Monitor.get_status() status = CortexStatus.Services.Monitor.get_status()
{:ok, {:ok, assign(socket, status: status, page_title: "Cortex Status")}
assign(socket,
status: status,
page_title: "Cortex Status",
selected_session: nil,
session_logs: [],
modal_loading: false
)}
end end
@impl true @impl true
@ -28,42 +21,6 @@ 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"""
@ -103,27 +60,15 @@ defmodule CortexStatusWeb.StatusLive do
/> />
</div> </div>
<.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>
</p> </p>
</footer> </footer>
</div> </div>
""" """
end end
# ── Service Card Component ──
attr :name, :string, required: true attr :name, :string, required: true
attr :status, :atom, required: true attr :status, :atom, required: true
attr :checked_at, :any, default: nil attr :checked_at, :any, default: nil
@ -156,163 +101,6 @@ defmodule CortexStatusWeb.StatusLive do
""" """
end end
# ── Engram Section Component ──
attr :engram, :map, required: true
defp engram_section(assigns) do
stats = assigns.engram.stats
sessions = assigns.engram.sessions
real_sessions =
sessions
|> Enum.reject(fn s -> get_val(s, "session_type", "") == "test" end)
|> Enum.sort_by(fn s -> get_val(s, "started_at", "") end, :desc)
total = get_val(stats, "total_sessions", 0)
total_logs = get_val(stats, "total_logs", 0)
active = get_val(stats, "active_sessions", 0)
assigns =
assigns
|> assign(:real_sessions, real_sessions)
|> assign(:total, total)
|> assign(:total_logs, total_logs)
|> assign(:active, active)
~H"""
<div class="engram-section">
<div class="engram-header">
<h2>Engram</h2>
<span class="engram-subtitle">Cross-session memory</span>
</div>
<div class="engram-stats">
<div class="engram-stat">
<span class="engram-stat-value"><%= @total %></span>
<span class="engram-stat-label">Sessions</span>
</div>
<div class="engram-stat">
<span class="engram-stat-value"><%= @total_logs %></span>
<span class="engram-stat-label">Log entries</span>
</div>
<div class="engram-stat">
<span class={"engram-stat-value #{if @active > 0, do: "active-pulse", else: ""}"}>
<%= @active %>
</span>
<span class="engram-stat-label">Active now</span>
</div>
</div>
<div class="engram-sessions">
<%= for session <- @real_sessions do %>
<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", "\u2014") %></span>
<span class="session-date"><%= format_session_date(get_val(session, "started_at", "")) %></span>
</div>
<% end %>
<%= if @real_sessions == [] do %>
<p class="engram-empty">No sessions recorded yet.</p>
<% end %>
</div>
</div>
"""
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
@ -345,17 +133,11 @@ defmodule CortexStatusWeb.StatusLive do
defp status_label(:error), do: "Down" defp status_label(:error), do: "Down"
defp status_label(_), do: "Checking" defp status_label(_), do: "Checking"
defp session_type_class("cowork"), do: "type-cowork"
defp session_type_class("code"), do: "type-code"
defp session_type_class("desktop"), do: "type-desktop"
defp session_type_class("api"), do: "type-api"
defp session_type_class(_), do: "type-other"
defp server_details(%{status: :ok, data: data}) do defp server_details(%{status: :ok, data: data}) 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] || "\u2014"} {"Uptime", data[:uptime_human] || ""}
] ]
end end
@ -367,14 +149,14 @@ defmodule CortexStatusWeb.StatusLive do
defp symbiont_details(%{status: :ok, data: data}) do defp symbiont_details(%{status: :ok, data: data}) do
[ [
{"Queue", "#{get_val(data, "queue_size", 0)} tasks"}, {"Queue", "#{data["queue_size"] || 0} tasks"},
{"Rate Limited", if(get_val(data, "rate_limited", false), do: "Yes", else: "No")}, {"Rate Limited", if(data["rate_limited"], do: "Yes", else: "No")},
{"Last Heartbeat", get_val(data, "last_heartbeat", "\u2014")} {"Last Heartbeat", data["last_heartbeat"] || ""}
] ]
end end
defp symbiont_details(%{status: :error, data: data}) do defp symbiont_details(%{status: :error, data: data}) do
[{"Error", get_val(data, "error", "Unreachable")}] [{"Error", data["error"] || "Unreachable"}]
end end
defp symbiont_details(_), do: [] defp symbiont_details(_), do: []
@ -384,40 +166,28 @@ defmodule CortexStatusWeb.StatusLive do
end end
defp dendrite_details(%{status: :error, data: data}) do defp dendrite_details(%{status: :error, data: data}) do
[{"Error", get_val(data, "error", "Unreachable")}] [{"Error", data["error"] || "Unreachable"}]
end end
defp dendrite_details(_), do: [] defp dendrite_details(_), do: []
defp websites_details(%{data: %{"sites" => sites}}) when is_list(sites) do defp websites_details(%{data: %{"sites" => sites}}) when is_list(sites) do
Enum.map(sites, fn site -> Enum.map(sites, fn site ->
name = get_val(site, :name, nil) || get_val(site, "name", "?")
status = get_val(site, :status, nil) || get_val(site, "status", nil)
latency = get_val(site, :latency_ms, nil) || get_val(site, "latency_ms", nil)
http_code = get_val(site, :http_code, nil) || get_val(site, "http_code", nil)
error = get_val(site, :error, nil) || get_val(site, "error", nil)
status_str = status_str =
case status do case site[:status] do
:ok -> "#{latency}ms" :ok -> "#{site[:latency_ms]}ms"
:degraded -> "HTTP #{http_code}" :degraded -> "⚠ HTTP #{site[:http_code]}"
:error -> to_string(error || "down") :error -> "#{site[:error]}"
_ -> "\u2014" _ -> "?"
end end
{name, status_str} {site[:name] || "?", status_str}
end) end)
end end
defp websites_details(_), do: [] defp websites_details(_), do: []
defp get_val(map, key, default) when is_map(map) do defp format_ago(nil), do: ""
Map.get(map, key, default)
end
defp get_val(_not_map, _key, default), do: default
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)
@ -430,63 +200,5 @@ defmodule CortexStatusWeb.StatusLive do
end end
end end
defp format_ago(_), do: "\u2014" defp format_ago(_), do: ""
defp format_session_date(""), do: "\u2014"
defp format_session_date(iso_str) when is_binary(iso_str) do
case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> format_relative_date(dt)
_ ->
case NaiveDateTime.from_iso8601(iso_str) do
{:ok, ndt} -> format_relative_date(DateTime.from_naive!(ndt, "Etc/UTC"))
_ -> String.slice(iso_str, 0, 10)
end
end
end
defp format_session_date(_), do: "\u2014"
defp format_relative_date(dt) do
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
days = div(diff, 86400)
cond do
days == 0 -> "today"
days == 1 -> "yesterday"
days < 7 -> "#{days}d ago"
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 end

View File

@ -1,366 +0,0 @@
defmodule CortexStatusWeb.TaskLive do
@moduledoc """
Mission Control session history browser powered by Engram.
Shows all recorded sessions with clickable detail views including
activity log timelines.
"""
use CortexStatusWeb, :live_view
@http_timeout 5_000
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
end
@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",
sessions: [],
stats: %{},
selected_session: nil,
session_logs: [],
modal_loading: false,
filter: "all",
loading: true
)}
end
@impl true
def handle_info(:load_sessions, socket) do
{stats, sessions} = fetch_engram_data()
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
end
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_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)
_ ->
[]
end
{:noreply, assign(socket, session_logs: logs, modal_loading: false)}
end
@impl true
def handle_event("select_session", %{"id" => session_id}, socket) do
session = Enum.find(socket.assigns.sessions, fn s -> gv(s, "id") == session_id 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"""
<div class="mc-page">
<header class="mc-header">
<div class="mc-header-left">
<h1>Mission Control</h1>
<span class="mc-subtitle">Engram Session History</span>
</div>
<a href="/" class="mc-back-link">&larr; Status</a>
</header>
<div class="mc-stats-bar">
<div class="mc-stat">
<span class="mc-stat-num"><%= gv(@stats, "total_sessions", 0) %></span>
<span class="mc-stat-lbl">Sessions</span>
</div>
<div class="mc-stat">
<span class="mc-stat-num"><%= gv(@stats, "total_logs", 0) %></span>
<span class="mc-stat-lbl">Log entries</span>
</div>
<div class="mc-stat">
<span class={"mc-stat-num #{if gv(@stats, "active_sessions", 0) > 0, do: "mc-active", else: ""}"}>
<%= gv(@stats, "active_sessions", 0) %>
</span>
<span class="mc-stat-lbl">Active now</span>
</div>
</div>
<div class="mc-filters">
<button
class={"mc-filter-btn #{if @filter == "all", do: "active", else: ""}"}
phx-click="filter"
phx-value-type="all"
>All <span class="mc-filter-count"><%= @total_real %></span></button>
<%= for {type, count} <- Enum.sort(@type_counts) do %>
<button
class={"mc-filter-btn #{if @filter == type, do: "active", else: ""}"}
phx-click="filter"
phx-value-type={type}
><%= type %> <span class="mc-filter-count"><%= count %></span></button>
<% end %>
</div>
<%= if @loading do %>
<div class="mc-loading">Loading sessions...</div>
<% else %>
<div class="mc-session-list">
<%= for session <- @filtered do %>
<div
class={"mc-session-card #{gv(session, "status", "unknown")}"}
phx-click="select_session"
phx-value-id={gv(session, "id", "")}
>
<div class="mc-card-top">
<span class={"mc-type-badge mc-type-#{gv(session, "session_type", "other")}"}><%= gv(session, "session_type", "?") %></span>
<span class={"mc-status-pill mc-st-#{gv(session, "status", "unknown")}"}><%= gv(session, "status", "?") %></span>
<span class="mc-card-date"><%= fmt_date(gv(session, "started_at", "")) %></span>
</div>
<div class="mc-card-summary"><%= gv(session, "summary", "\u2014") %></div>
<%= if gv(session, "completion_summary", nil) do %>
<div class="mc-card-outcome"><%= gv(session, "completion_summary", "") %></div>
<% end %>
</div>
<% end %>
<%= if @filtered == [] do %>
<div class="mc-empty">No sessions match this filter.</div>
<% end %>
</div>
<% end %>
<%= if @selected_session do %>
<.session_modal
session={@selected_session}
logs={@session_logs}
loading={@modal_loading}
/>
<% end %>
</div>
"""
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
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))
~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={"mc-type-badge mc-type-#{@stype}"}><%= @stype %></span>
<span class={"mc-status-pill mc-st-#{@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"><%= @sid %></span>
</div>
<div class="meta-item">
<span class="meta-label">Started</span>
<span class="meta-value"><%= fmt_full(@started) %></span>
</div>
<%= if @completed do %>
<div class="meta-item">
<span class="meta-label">Completed</span>
<span class="meta-value"><%= fmt_full(@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"><%= fmt_time(gv(log, "timestamp", "")) %></span>
<span class="log-text"><%= gv(log, "entry", "") %></span>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
"""
end
# ── Data Fetching ──
defp fetch_engram_data do
stats_task =
Task.async(fn ->
case Req.get("#{symbiont_url()}/engram/stats", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: body}} when is_map(body) -> body
_ -> %{}
end
end)
sessions_task =
Task.async(fn ->
case Req.get("#{symbiont_url()}/engram/sessions/recent?hours=8760",
receive_timeout: @http_timeout
) do
{:ok, %{status: 200, body: %{"sessions" => s}}} when is_list(s) -> s
_ -> []
end
end)
stats = Task.await(stats_task, @http_timeout + 1_000)
sessions = Task.await(sessions_task, @http_timeout + 1_000)
{stats, sessions}
end
# ── Helpers ──
defp gv(map, key, default \\ nil) when is_map(map), do: Map.get(map, key, default)
defp gv(_, _, default), do: default
defp fmt_date(""), do: "\u2014"
defp fmt_date(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} ->
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
days = div(diff, 86400)
cond do
days == 0 -> "today"
days == 1 -> "yesterday"
days < 7 -> "#{days}d ago"
true -> Calendar.strftime(dt, "%b %d")
end
_ ->
String.slice(iso, 0, 10)
end
end
defp fmt_date(_), do: "\u2014"
defp fmt_full(nil), do: "\u2014"
defp fmt_full(""), do: "\u2014"
defp fmt_full(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} -> Calendar.strftime(dt, "%b %d, %Y at %H:%M UTC")
_ -> iso
end
end
defp fmt_full(_), do: "\u2014"
defp fmt_time(""), do: ""
defp fmt_time(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} -> Calendar.strftime(dt, "%H:%M")
_ -> ""
end
end
defp fmt_time(_), do: ""
defp parse_dt(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> {:ok, dt}
_ ->
case NaiveDateTime.from_iso8601(iso) do
{:ok, ndt} -> {:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
_ -> :error
end
end
end
end

View File

@ -20,22 +20,14 @@ defmodule CortexStatusWeb.Router do
pipe_through :browser pipe_through :browser
live "/", StatusLive, :index live "/", StatusLive, :index
live "/tasks", TaskLive, :index
end end
# Phoenix LiveDashboard with custom pages # Standard Phoenix LiveDashboard for BEAM VM metrics
scope "/" do scope "/" do
pipe_through :browser pipe_through :browser
live_dashboard "/dashboard", live_dashboard "/dashboard",
metrics: CortexStatusWeb.Telemetry, metrics: CortexStatusWeb.Telemetry
additional_pages: [
cortex_health: CortexStatusWeb.DashboardPages.ServerHealthPage,
symbiont: CortexStatusWeb.DashboardPages.SymbiontPage,
dendrite: CortexStatusWeb.DashboardPages.DendritePage,
websites: CortexStatusWeb.DashboardPages.WebsitesPage,
tasks: CortexStatusWeb.DashboardPages.TasksPage
]
end end
# JSON API for external consumption # JSON API for external consumption

View File

@ -2,7 +2,6 @@
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
@ -13,7 +12,6 @@
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},