Add Engram section to status page — session summaries + stats
This commit is contained in:
parent
51f741965f
commit
824bf31f0c
@ -19,6 +19,7 @@ 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"}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -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") || "status.hydrascale.net"
|
host = System.get_env("PHX_HOST") || "cortex.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"},
|
||||||
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"},
|
{"git.hydrascale.net", "https://git.hydrascale.net"},
|
||||||
{"cortex.hydrascale.net", "https://cortex.hydrascale.net"}
|
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
473
elixir_notes.md
Normal file
473
elixir_notes.md
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
# 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).
|
||||||
@ -34,7 +34,8 @@ 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
|
||||||
@ -71,7 +72,8 @@ 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 =
|
||||||
@ -86,8 +88,12 @@ 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)
|
||||||
@ -95,12 +101,10 @@ defmodule CortexStatus.Services.Monitor do
|
|||||||
|
|
||||||
defp check_server do
|
defp check_server do
|
||||||
try do
|
try do
|
||||||
# Use os_mon for local BEAM host metrics
|
cpu_load = :cpu_sup.avg1() / 256
|
||||||
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()
|
||||||
|
|
||||||
@ -219,6 +223,34 @@ 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
81
lib/cortex_status_web/live/dashboard_pages/tasks_page.ex
Normal file
81
lib/cortex_status_web/live/dashboard_pages/tasks_page.ex
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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
|
||||||
@ -60,15 +60,19 @@ defmodule CortexStatusWeb.StatusLive do
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.engram_section engram={@status.engram} />
|
||||||
|
|
||||||
<footer class="status-footer">
|
<footer class="status-footer">
|
||||||
<p>Checks run every 15 seconds •
|
<p>Checks run every 15 seconds •
|
||||||
<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
|
||||||
@ -101,6 +105,73 @@ 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
|
||||||
|
|
||||||
|
# Filter out test sessions and sort by started_at desc
|
||||||
|
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">
|
||||||
|
<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-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
|
||||||
|
|
||||||
# -- Helpers --
|
# -- Helpers --
|
||||||
|
|
||||||
defp overall_status(status) do
|
defp overall_status(status) do
|
||||||
@ -133,6 +204,12 @@ 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]}%"},
|
||||||
@ -149,14 +226,14 @@ defmodule CortexStatusWeb.StatusLive do
|
|||||||
|
|
||||||
defp symbiont_details(%{status: :ok, data: data}) do
|
defp symbiont_details(%{status: :ok, data: data}) do
|
||||||
[
|
[
|
||||||
{"Queue", "#{data["queue_size"] || 0} tasks"},
|
{"Queue", "#{get_val(data, "queue_size", 0)} tasks"},
|
||||||
{"Rate Limited", if(data["rate_limited"], do: "Yes", else: "No")},
|
{"Rate Limited", if(get_val(data, "rate_limited", false), do: "Yes", else: "No")},
|
||||||
{"Last Heartbeat", data["last_heartbeat"] || "—"}
|
{"Last Heartbeat", get_val(data, "last_heartbeat", "—")}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp symbiont_details(%{status: :error, data: data}) do
|
defp symbiont_details(%{status: :error, data: data}) do
|
||||||
[{"Error", data["error"] || "Unreachable"}]
|
[{"Error", get_val(data, "error", "Unreachable")}]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp symbiont_details(_), do: []
|
defp symbiont_details(_), do: []
|
||||||
@ -166,27 +243,39 @@ defmodule CortexStatusWeb.StatusLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dendrite_details(%{status: :error, data: data}) do
|
defp dendrite_details(%{status: :error, data: data}) do
|
||||||
[{"Error", data["error"] || "Unreachable"}]
|
[{"Error", get_val(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 site[:status] do
|
case status do
|
||||||
:ok -> "✓ #{site[:latency_ms]}ms"
|
:ok -> "#{latency}ms"
|
||||||
:degraded -> "⚠ HTTP #{site[:http_code]}"
|
:degraded -> "HTTP #{http_code}"
|
||||||
:error -> "✗ #{site[:error]}"
|
:error -> to_string(error || "down")
|
||||||
_ -> "?"
|
_ -> "—"
|
||||||
end
|
end
|
||||||
|
|
||||||
{site[:name] || "?", status_str}
|
{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
|
||||||
|
Map.get(map, key, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_val(_not_map, _key, default), do: default
|
||||||
|
|
||||||
defp format_ago(nil), do: "—"
|
defp format_ago(nil), do: "—"
|
||||||
|
|
||||||
defp format_ago(%DateTime{} = dt) do
|
defp format_ago(%DateTime{} = dt) do
|
||||||
@ -201,4 +290,33 @@ defmodule CortexStatusWeb.StatusLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp format_ago(_), do: "—"
|
defp format_ago(_), do: "—"
|
||||||
|
|
||||||
|
defp format_session_date(""), 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
|
||||||
|
{: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: "—"
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
559
lib/cortex_status_web/live/task_live.ex
Normal file
559
lib/cortex_status_web/live/task_live.ex
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
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.
|
||||||
|
"""
|
||||||
|
use CortexStatusWeb, :live_view
|
||||||
|
|
||||||
|
@poll_interval 1_000
|
||||||
|
@auth_token "cortex-tasks-2026"
|
||||||
|
|
||||||
|
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
|
||||||
|
{: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
|
||||||
|
)}
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("update_prompt", %{"prompt" => prompt}, socket) do
|
||||||
|
{:noreply, assign(socket, prompt: prompt)}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:poll_task, socket) do
|
||||||
|
case socket.assigns.active_task do
|
||||||
|
nil ->
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mission-control">
|
||||||
|
<%= if !@authenticated do %>
|
||||||
|
<.auth_gate auth_error={@auth_error} />
|
||||||
|
<% else %>
|
||||||
|
<.task_interface
|
||||||
|
prompt={@prompt}
|
||||||
|
submitting={@submitting}
|
||||||
|
active_task={@active_task}
|
||||||
|
task_history={@task_history}
|
||||||
|
submit_error={@submit_error}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :auth_error, :string, default: nil
|
||||||
|
|
||||||
|
defp auth_gate(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="auth-gate">
|
||||||
|
<div class="auth-container">
|
||||||
|
<h1>Mission Control</h1>
|
||||||
|
<p class="auth-subtitle">Symbiont Task Orchestration</p>
|
||||||
|
|
||||||
|
<%= if @auth_error do %>
|
||||||
|
<div class="auth-error">
|
||||||
|
<%= @auth_error %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<form phx-submit="authenticate" id="auth-form" class="auth-form">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password-input"
|
||||||
|
name="password"
|
||||||
|
placeholder="Enter authorization token"
|
||||||
|
class="auth-input"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button type="submit" class="auth-button">Access Mission Control</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">
|
||||||
|
<a href="/">← Back to Status</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :prompt, :string, default: ""
|
||||||
|
attr :submitting, :boolean, default: false
|
||||||
|
attr :active_task, :any, default: nil
|
||||||
|
attr :task_history, :list, default: []
|
||||||
|
attr :submit_error, :string, default: nil
|
||||||
|
|
||||||
|
defp task_interface(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="task-interface">
|
||||||
|
<header class="task-header">
|
||||||
|
<h1>Mission Control</h1>
|
||||||
|
<button phx-click="logout" class="logout-button">Logout</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="task-main">
|
||||||
|
<div class="task-input-section">
|
||||||
|
<form phx-submit="submit_task" phx-change="update_prompt" class="task-form">
|
||||||
|
<label for="prompt-input">Compound Task Prompt</label>
|
||||||
|
<textarea
|
||||||
|
id="prompt-input"
|
||||||
|
name="prompt"
|
||||||
|
class="task-textarea"
|
||||||
|
placeholder="Describe the task to decompose..."
|
||||||
|
><%= @prompt %></textarea>
|
||||||
|
<%= if @submit_error do %>
|
||||||
|
<div class="submit-error"><%= @submit_error %></div>
|
||||||
|
<% end %>
|
||||||
|
<button type="submit" class="execute-button" disabled={@submitting}>
|
||||||
|
<%= if @submitting, do: "Submitting...", else: "Execute Task" %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @active_task do %>
|
||||||
|
<.task_visualization task={@active_task} />
|
||||||
|
<% else %>
|
||||||
|
<.task_history history={@task_history} />
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp task_visualization(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="task-visualization">
|
||||||
|
<div class="task-metadata">
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Task ID</span>
|
||||||
|
<span class="metadata-value"><%= @task["id"] %></span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Status</span>
|
||||||
|
<span class={"metadata-status status-#{@task["status"]}"}>
|
||||||
|
<%= String.upcase(@task["status"]) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Cost</span>
|
||||||
|
<span class="metadata-value">$<%= format_cost(@task["total_cost"]) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="metadata-label">Subtasks</span>
|
||||||
|
<span class="metadata-value"><%= length(@task["subtasks"] || []) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-card">
|
||||||
|
<div class="prompt-label">Original Prompt</div>
|
||||||
|
<div class="prompt-text"><%= @task["prompt"] %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @task["reasoning"] do %>
|
||||||
|
<div class="reasoning-card">
|
||||||
|
<div class="reasoning-label">Decomposition Reasoning</div>
|
||||||
|
<div class="reasoning-text"><%= @task["reasoning"] %></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="task-flow">
|
||||||
|
<div class="flow-header">Task Flow Visualization</div>
|
||||||
|
<.task_flow_graph subtasks={@task["subtasks"] || []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-details">
|
||||||
|
<div class="details-header">Subtask Details</div>
|
||||||
|
<div class="subtasks-list">
|
||||||
|
<%= for subtask <- @task["subtasks"] || [] do %>
|
||||||
|
<.subtask_detail subtask={subtask} />
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp task_flow_graph(assigns) do
|
||||||
|
# Group subtasks by wave (dependency level)
|
||||||
|
subtasks = assigns.subtasks || []
|
||||||
|
waves = compute_waves(subtasks)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="flow-container">
|
||||||
|
<%= for {wave_tasks, wave_num} <- Enum.with_index(waves) do %>
|
||||||
|
<div class="wave-row">
|
||||||
|
<div class="wave-label">Wave <%= wave_num %></div>
|
||||||
|
<div class="wave-tasks">
|
||||||
|
<%= for subtask <- wave_tasks do %>
|
||||||
|
<.subtask_card subtask={subtask} />
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp subtask_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class={"subtask-card status-#{@subtask["status"]}"}>
|
||||||
|
<div class="card-header-row">
|
||||||
|
<div class="card-status-icon">
|
||||||
|
<%= case @subtask["status"] do %>
|
||||||
|
<% "completed" -> %>
|
||||||
|
✓
|
||||||
|
<% "failed" -> %>
|
||||||
|
✗
|
||||||
|
<% "executing" -> %>
|
||||||
|
◆
|
||||||
|
<% _ -> %>
|
||||||
|
◇
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">
|
||||||
|
<%= String.slice(@subtask["description"], 0..40) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-meta">
|
||||||
|
<%= if @subtask["model"] do %>
|
||||||
|
<span class={"model-badge tier-#{@subtask["tier_assigned"]}"}>
|
||||||
|
<%= @subtask["model"] %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @subtask["status"] == "executing" do %>
|
||||||
|
<div class="executing-indicator">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
Executing...
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @subtask["result"] do %>
|
||||||
|
<div class="card-result">
|
||||||
|
<%= String.slice(@subtask["result"], 0..60) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="card-cost">
|
||||||
|
$<%= format_cost(@subtask["cost"]) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp subtask_detail(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class={"subtask-item status-#{@subtask["status"]}"}>
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-index">
|
||||||
|
Subtask <%= @subtask["index"] %>
|
||||||
|
</span>
|
||||||
|
<span class={"item-status status-#{@subtask["status"]}"}>
|
||||||
|
<%= String.upcase(@subtask["status"]) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-description">
|
||||||
|
<%= @subtask["description"] %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-row">
|
||||||
|
<span class="item-meta">
|
||||||
|
<strong>Model:</strong> <%= @subtask["model"] %>
|
||||||
|
</span>
|
||||||
|
<span class="item-meta">
|
||||||
|
<strong>Tier:</strong> <%= @subtask["tier_assigned"] %>
|
||||||
|
</span>
|
||||||
|
<span class="item-meta">
|
||||||
|
<strong>Cost:</strong> $<%= format_cost(@subtask["cost"]) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @subtask["started_at"] do %>
|
||||||
|
<div class="item-row">
|
||||||
|
<span class="item-meta">
|
||||||
|
<strong>Started:</strong> <%= format_datetime(@subtask["started_at"]) %>
|
||||||
|
</span>
|
||||||
|
<%= if @subtask["completed_at"] do %>
|
||||||
|
<span class="item-meta">
|
||||||
|
<strong>Completed:</strong> <%= format_datetime(@subtask["completed_at"]) %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @subtask["result"] do %>
|
||||||
|
<div class="item-result">
|
||||||
|
<strong>Result:</strong>
|
||||||
|
<pre><%= @subtask["result"] %></pre>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @subtask["depends_on"] && length(@subtask["depends_on"]) > 0 do %>
|
||||||
|
<div class="item-deps">
|
||||||
|
<strong>Depends on:</strong> <%= Enum.join(@subtask["depends_on"], ", ") %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp task_history(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="task-history">
|
||||||
|
<div class="history-header">Recent Tasks</div>
|
||||||
|
|
||||||
|
<%= if length(@history) == 0 do %>
|
||||||
|
<div class="history-empty">
|
||||||
|
<p>No tasks yet. Create your first compound task above.</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="history-list">
|
||||||
|
<%= for task <- @history do %>
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-item-header">
|
||||||
|
<span class="history-item-id"><%= task["id"] %></span>
|
||||||
|
<span class={"history-item-status status-#{task["status"]}"}>
|
||||||
|
<%= String.upcase(task["status"]) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-item-prompt">
|
||||||
|
<%= String.slice(task["prompt"], 0..100) %>
|
||||||
|
</div>
|
||||||
|
<div class="history-item-meta">
|
||||||
|
<span><%= task["subtask_count"] || length(task["subtasks"] || []) %> subtasks</span>
|
||||||
|
<span class="history-item-cost">
|
||||||
|
$<%= format_cost(task["total_cost"]) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# -- Helpers --
|
||||||
|
|
||||||
|
defp submit_compound_task(prompt) do
|
||||||
|
case Req.post("#{symbiont_url()}/task/compound",
|
||||||
|
json: %{"prompt" => prompt},
|
||||||
|
headers: [{"authorization", "Bearer #{@auth_token}"}]
|
||||||
|
) do
|
||||||
|
{:ok, response} ->
|
||||||
|
case response.status do
|
||||||
|
200 -> {:ok, response.body}
|
||||||
|
_ -> {:error, "HTTP #{response.status}"}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_task_progress(task_id) do
|
||||||
|
case Req.get("#{symbiont_url()}/task/#{task_id}/progress") do
|
||||||
|
{:ok, response} ->
|
||||||
|
case response.status do
|
||||||
|
200 -> {:ok, response.body}
|
||||||
|
_ -> {:error, "HTTP #{response.status}"}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_recent_tasks do
|
||||||
|
case Req.get("#{symbiont_url()}/tasks/recent") do
|
||||||
|
{:ok, response} ->
|
||||||
|
case response.status do
|
||||||
|
200 ->
|
||||||
|
case response.body do
|
||||||
|
list when is_list(list) -> list
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_waves(subtasks) do
|
||||||
|
# Group subtasks by dependency level (wave)
|
||||||
|
# Wave 0: no dependencies, Wave N: max(dep waves) + 1
|
||||||
|
# Sort by index first to ensure deps are computed before dependents
|
||||||
|
sorted = Enum.sort_by(subtasks, & &1["index"])
|
||||||
|
|
||||||
|
wave_map =
|
||||||
|
Enum.reduce(sorted, %{}, fn task, acc ->
|
||||||
|
deps = task["depends_on"] || []
|
||||||
|
|
||||||
|
wave =
|
||||||
|
if Enum.empty?(deps) do
|
||||||
|
0
|
||||||
|
else
|
||||||
|
deps
|
||||||
|
|> Enum.map(&Map.get(acc, &1, 0))
|
||||||
|
|> Enum.max(fn -> 0 end)
|
||||||
|
|> Kernel.+(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
Map.put(acc, task["index"], wave)
|
||||||
|
end)
|
||||||
|
|
||||||
|
subtasks
|
||||||
|
|> Enum.group_by(&Map.get(wave_map, &1["index"], 0))
|
||||||
|
|> Enum.sort_by(fn {wave, _} -> wave end)
|
||||||
|
|> Enum.map(fn {_, tasks} -> Enum.sort_by(tasks, &(&1["index"])) 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_datetime(iso_string) when is_binary(iso_string) do
|
||||||
|
case DateTime.from_iso8601(iso_string) do
|
||||||
|
{:ok, dt, _} ->
|
||||||
|
dt
|
||||||
|
|> DateTime.to_naive()
|
||||||
|
|> NaiveDateTime.to_string()
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
iso_string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(nil), do: "—"
|
||||||
|
end
|
||||||
@ -20,14 +20,22 @@ defmodule CortexStatusWeb.Router do
|
|||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
live "/", StatusLive, :index
|
live "/", StatusLive, :index
|
||||||
|
live "/tasks", TaskLive, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
# Standard Phoenix LiveDashboard for BEAM VM metrics
|
# Phoenix LiveDashboard with custom pages
|
||||||
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
|
||||||
|
|||||||
2
mix.lock
2
mix.lock
@ -2,6 +2,7 @@
|
|||||||
"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"},
|
||||||
@ -12,6 +13,7 @@
|
|||||||
"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"},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user