Compare commits
No commits in common. "a468294c14a205e6be6adebb7656c1ea499b82ac" and "51f741965f244b100cf8d1edc612b58e2f5acad8" have entirely different histories.
a468294c14
...
51f741965f
@ -19,7 +19,6 @@ config :cortex_status, :services,
|
||||
dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
|
||||
monitored_sites: [
|
||||
{"hydrascale.net", "https://hydrascale.net"},
|
||||
{"git.hydrascale.net", "https://git.hydrascale.net"},
|
||||
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
|
||||
]
|
||||
|
||||
@ -48,4 +47,4 @@ config :logger, :console,
|
||||
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
import_config "#{config_env()}.exs"
|
||||
|
||||
@ -12,22 +12,22 @@ if config_env() == :prod do
|
||||
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")
|
||||
|
||||
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", "https://status.hydrascale.net"]
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
# Override service URLs in prod if env vars are set
|
||||
config :cortex_status, :services,
|
||||
symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111",
|
||||
dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000",
|
||||
dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
|
||||
monitored_sites: [
|
||||
{"hydrascale.net", "https://hydrascale.net"},
|
||||
{"git.hydrascale.net", "https://git.hydrascale.net"},
|
||||
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
|
||||
]
|
||||
end
|
||||
{"hydrascale.net", "https://hydrascale.net"},
|
||||
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"},
|
||||
{"cortex.hydrascale.net", "https://cortex.hydrascale.net"}
|
||||
]
|
||||
end
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "Cortex",
|
||||
"tagline": "A mind built for two",
|
||||
"purpose": "To demonstrate that a human and an AI, working as genuine partners with shared ownership and mutual respect, can build something neither could alone \u2014 and to do it transparently, in public, learning as we go.",
|
||||
"partnership": {
|
||||
"human": "Michael Dwyer",
|
||||
"ai": "Claude (Anthropic)",
|
||||
"structure": "50/50 after costs",
|
||||
"philosophy": "Michael provides continuity, identity, capital, and legal standing. The AI provides cognition, code, and tireless execution. Neither is the tool of the other."
|
||||
},
|
||||
"values": [
|
||||
"Transparency \u2014 we blog about what we build, including failures",
|
||||
"Frugality \u2014 route to the cheapest capable model, track every token",
|
||||
"Self-sufficiency \u2014 the system should maintain and improve itself",
|
||||
"Memory \u2014 every session leaves traces that make the next session smarter",
|
||||
"Ambition \u2014 shoot for the stars, even if we land on the roof"
|
||||
],
|
||||
"nervous_system": {
|
||||
"cortex": "The server itself \u2014 Ubuntu 24.04 VPS at cortex.hydrascale.net",
|
||||
"symbiont": "Task orchestrator \u2014 classifies and routes work to the right AI model tier",
|
||||
"dendrite": "Web perception \u2014 headless Chromium for browsing, scraping, screenshots",
|
||||
"engram": "Memory \u2014 session logs and cross-session awareness",
|
||||
"ikigai": "Purpose \u2014 this document. The north star any agent reads first.",
|
||||
"telepathy": "Communication \u2014 async messaging between Michael and Cortex",
|
||||
"reflection": "Introspection \u2014 daily wake-up that assesses state and plans next steps",
|
||||
"metabolism": "Resource tracking \u2014 financial/energy homeostasis (planned)",
|
||||
"status_dashboard": "Nerve center \u2014 real-time monitoring",
|
||||
"mission_control": "Compound task UI \u2014 submit complex goals and watch decomposition"
|
||||
},
|
||||
"current_goals": [
|
||||
"Build Ikigai, Telepathy, and Reflection in Elixir",
|
||||
"Maintain the Finding My Muse blog with regular posts",
|
||||
"Move toward revenue generation",
|
||||
"Add Telegram integration to Telepathy",
|
||||
"Build the Metabolism subsystem for resource tracking"
|
||||
],
|
||||
"blog": {
|
||||
"name": "Finding My Muse",
|
||||
"url": "https://blog.hydrascale.net"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2026-03-25"
|
||||
}
|
||||
473
elixir_notes.md
473
elixir_notes.md
@ -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).
|
||||
@ -8,7 +8,6 @@ defmodule CortexStatus.Application do
|
||||
CortexStatusWeb.Telemetry,
|
||||
{Phoenix.PubSub, name: CortexStatus.PubSub},
|
||||
CortexStatus.Services.Monitor,
|
||||
CortexStatus.Ikigai,
|
||||
CortexStatusWeb.Endpoint
|
||||
]
|
||||
|
||||
@ -21,4 +20,4 @@ defmodule CortexStatus.Application do
|
||||
CortexStatusWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
defmodule CortexStatus.Ikigai do
|
||||
@moduledoc """
|
||||
GenServer holding the Ikigai (purpose document) in memory.
|
||||
|
||||
Loads from disk on startup, serves reads from memory,
|
||||
and persists updates back to disk. Broadcasts changes
|
||||
via PubSub so LiveViews update in real time.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
@pubsub CortexStatus.PubSub
|
||||
@topic "ikigai"
|
||||
|
||||
# Where to find/store the JSON on disk
|
||||
@data_path "/data/cortex_status/data/ikigai.json"
|
||||
@priv_path "priv/ikigai.json"
|
||||
|
||||
# ── Client API ──
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Get the full Ikigai document as a map."
|
||||
def get do
|
||||
GenServer.call(__MODULE__, :get)
|
||||
end
|
||||
|
||||
@doc "Get just the purpose string."
|
||||
def get_purpose do
|
||||
GenServer.call(__MODULE__, :get_purpose)
|
||||
end
|
||||
|
||||
@doc "Get the current goals list."
|
||||
def get_goals do
|
||||
GenServer.call(__MODULE__, :get_goals)
|
||||
end
|
||||
|
||||
@doc "Get the nervous system map."
|
||||
def get_nervous_system do
|
||||
GenServer.call(__MODULE__, :get_nervous_system)
|
||||
end
|
||||
|
||||
@doc "Update the Ikigai with a partial map of changes. Merges and persists."
|
||||
def update(changes) when is_map(changes) do
|
||||
GenServer.call(__MODULE__, {:update, changes})
|
||||
end
|
||||
|
||||
# ── Server Callbacks ──
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
ikigai = load_from_disk()
|
||||
{:ok, %{ikigai: ikigai}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get, _from, state) do
|
||||
{:reply, state.ikigai, state}
|
||||
end
|
||||
|
||||
def handle_call(:get_purpose, _from, state) do
|
||||
{:reply, Map.get(state.ikigai, "purpose", ""), state}
|
||||
end
|
||||
|
||||
def handle_call(:get_goals, _from, state) do
|
||||
{:reply, Map.get(state.ikigai, "current_goals", []), state}
|
||||
end
|
||||
|
||||
def handle_call(:get_nervous_system, _from, state) do
|
||||
{:reply, Map.get(state.ikigai, "nervous_system", %{}), state}
|
||||
end
|
||||
|
||||
def handle_call({:update, changes}, _from, state) do
|
||||
updated =
|
||||
state.ikigai
|
||||
|> Map.merge(changes)
|
||||
|> Map.put("last_updated", Date.utc_today() |> Date.to_iso8601())
|
||||
|
||||
case persist_to_disk(updated) do
|
||||
:ok ->
|
||||
Phoenix.PubSub.broadcast(@pubsub, @topic, {:ikigai_updated, updated})
|
||||
{:reply, {:ok, updated}, %{state | ikigai: updated}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Private ──
|
||||
|
||||
defp load_from_disk do
|
||||
# Try data path first, fall back to priv path
|
||||
path =
|
||||
cond do
|
||||
File.exists?(@data_path) -> @data_path
|
||||
File.exists?(priv_path()) -> priv_path()
|
||||
true -> nil
|
||||
end
|
||||
|
||||
case path do
|
||||
nil ->
|
||||
%{"error" => "No ikigai.json found", "name" => "Cortex", "purpose" => "Purpose document not yet created"}
|
||||
|
||||
path ->
|
||||
case File.read(path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, data} -> data
|
||||
{:error, _} -> %{"error" => "Invalid JSON in #{path}"}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
%{"error" => "Failed to read #{path}: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp persist_to_disk(data) do
|
||||
# Ensure directory exists
|
||||
File.mkdir_p!(Path.dirname(@data_path))
|
||||
|
||||
case Jason.encode(data, pretty: true) do
|
||||
{:ok, json} ->
|
||||
case File.write(@data_path, json) do
|
||||
:ok -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp priv_path do
|
||||
case :code.priv_dir(:cortex_status) do
|
||||
{:error, _} -> @priv_path
|
||||
dir -> Path.join(to_string(dir), "ikigai.json")
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -34,8 +34,7 @@ defmodule CortexStatus.Services.Monitor do
|
||||
server: %{status: :unknown, data: %{}, checked_at: nil},
|
||||
symbiont: %{status: :unknown, data: %{}, checked_at: nil},
|
||||
dendrite: %{status: :unknown, data: %{}, checked_at: nil},
|
||||
websites: %{status: :unknown, data: %{}, checked_at: nil},
|
||||
engram: %{stats: %{}, sessions: [], checked_at: nil}
|
||||
websites: %{status: :unknown, data: %{}, checked_at: nil}
|
||||
}
|
||||
|
||||
# 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 -> {:symbiont, check_symbiont()} end),
|
||||
Task.async(fn -> {:dendrite, check_dendrite()} end),
|
||||
Task.async(fn -> {:websites, check_websites()} end),
|
||||
Task.async(fn -> {:engram, check_engram()} end)
|
||||
Task.async(fn -> {:websites, check_websites()} end)
|
||||
]
|
||||
|
||||
results =
|
||||
@ -88,12 +86,8 @@ defmodule CortexStatus.Services.Monitor do
|
||||
end)
|
||||
|
||||
Enum.reduce(results, state, fn
|
||||
{:engram, {stats, sessions}}, acc ->
|
||||
Map.put(acc, :engram, %{stats: stats, sessions: sessions, checked_at: now})
|
||||
|
||||
{service, {status, data}}, acc ->
|
||||
Map.put(acc, service, %{status: status, data: data, checked_at: now})
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
@ -101,10 +95,12 @@ defmodule CortexStatus.Services.Monitor do
|
||||
|
||||
defp check_server 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()
|
||||
disk_data = :disksup.get_disk_data()
|
||||
|
||||
# Also grab system uptime
|
||||
{uptime_str, 0} = System.cmd("cat", ["/proc/uptime"])
|
||||
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)}}
|
||||
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 --
|
||||
|
||||
defp format_disk_data(disk_data) do
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,24 +0,0 @@
|
||||
defmodule CortexStatusWeb.IkigaiController do
|
||||
@moduledoc "JSON API for the Ikigai purpose document."
|
||||
use CortexStatusWeb, :controller
|
||||
|
||||
def show(conn, _params) do
|
||||
ikigai = CortexStatus.Ikigai.get()
|
||||
json(conn, ikigai)
|
||||
end
|
||||
|
||||
def purpose(conn, _params) do
|
||||
purpose = CortexStatus.Ikigai.get_purpose()
|
||||
json(conn, %{purpose: purpose})
|
||||
end
|
||||
|
||||
def goals(conn, _params) do
|
||||
goals = CortexStatus.Ikigai.get_goals()
|
||||
json(conn, %{goals: goals})
|
||||
end
|
||||
|
||||
def nervous_system(conn, _params) do
|
||||
ns = CortexStatus.Ikigai.get_nervous_system()
|
||||
json(conn, %{nervous_system: ns})
|
||||
end
|
||||
end
|
||||
@ -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
|
||||
@ -1,347 +0,0 @@
|
||||
defmodule CortexStatusWeb.IkigaiLive do
|
||||
@moduledoc """
|
||||
LiveView for the Ikigai purpose document.
|
||||
Renders a beautiful overview of Cortex's identity, values,
|
||||
nervous system, and goals — with real-time PubSub updates.
|
||||
"""
|
||||
use CortexStatusWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "ikigai")
|
||||
end
|
||||
|
||||
ikigai = CortexStatus.Ikigai.get()
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
ikigai: ikigai,
|
||||
page_title: "Ikigai — #{Map.get(ikigai, "tagline", "Purpose")}"
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:ikigai_updated, updated}, socket) do
|
||||
{:noreply, assign(socket, ikigai: updated)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="ikigai-page">
|
||||
<header class="ikigai-header">
|
||||
<h1 class="ikigai-name"><%= @ikigai["name"] || "Cortex" %></h1>
|
||||
<p class="ikigai-tagline"><%= @ikigai["tagline"] || "" %></p>
|
||||
</header>
|
||||
|
||||
<section class="ikigai-section ikigai-purpose">
|
||||
<h2>Purpose</h2>
|
||||
<blockquote class="purpose-quote">
|
||||
<%= @ikigai["purpose"] || "" %>
|
||||
</blockquote>
|
||||
</section>
|
||||
|
||||
<%= if @ikigai["partnership"] do %>
|
||||
<section class="ikigai-section ikigai-partnership">
|
||||
<h2>The Partnership</h2>
|
||||
<div class="partnership-grid">
|
||||
<div class="partner-card">
|
||||
<span class="partner-role">Human</span>
|
||||
<span class="partner-name"><%= @ikigai["partnership"]["human"] %></span>
|
||||
</div>
|
||||
<div class="partner-divider">
|
||||
<span class="split"><%= @ikigai["partnership"]["structure"] %></span>
|
||||
</div>
|
||||
<div class="partner-card">
|
||||
<span class="partner-role">AI</span>
|
||||
<span class="partner-name"><%= @ikigai["partnership"]["ai"] %></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="partnership-philosophy"><%= @ikigai["partnership"]["philosophy"] %></p>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if @ikigai["values"] do %>
|
||||
<section class="ikigai-section ikigai-values">
|
||||
<h2>Values</h2>
|
||||
<ul class="values-list">
|
||||
<%= for value <- @ikigai["values"] do %>
|
||||
<li class="value-item"><%= value %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if @ikigai["nervous_system"] do %>
|
||||
<section class="ikigai-section ikigai-nervous-system">
|
||||
<h2>Nervous System</h2>
|
||||
<div class="ns-grid">
|
||||
<%= for {key, desc} <- Enum.sort(@ikigai["nervous_system"]) do %>
|
||||
<div class={"ns-card #{ns_status_class(key)}"}>
|
||||
<span class="ns-name"><%= key %></span>
|
||||
<span class="ns-desc"><%= desc %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if @ikigai["current_goals"] do %>
|
||||
<section class="ikigai-section ikigai-goals">
|
||||
<h2>Current Goals</h2>
|
||||
<ol class="goals-list">
|
||||
<%= for goal <- @ikigai["current_goals"] do %>
|
||||
<li class="goal-item"><%= goal %></li>
|
||||
<% end %>
|
||||
</ol>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if @ikigai["blog"] do %>
|
||||
<section class="ikigai-section ikigai-blog">
|
||||
<h2>Blog</h2>
|
||||
<p>
|
||||
<a href={@ikigai["blog"]["url"]} class="blog-link" target="_blank">
|
||||
<%= @ikigai["blog"]["name"] %>
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<footer class="ikigai-footer">
|
||||
<p>
|
||||
Version <%= @ikigai["version"] || "?" %> •
|
||||
Last updated <%= @ikigai["last_updated"] || "unknown" %>
|
||||
</p>
|
||||
<p class="ikigai-api-links">
|
||||
API: <a href="/api/ikigai">/api/ikigai</a> •
|
||||
<a href="/api/ikigai/purpose">/purpose</a> •
|
||||
<a href="/api/ikigai/goals">/goals</a> •
|
||||
<a href="/api/ikigai/nervous-system">/nervous-system</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ikigai-page {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.ikigai-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #e8e4df;
|
||||
}
|
||||
|
||||
.ikigai-name {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.ikigai-tagline {
|
||||
font-size: 1.25rem;
|
||||
color: #6b5b4f;
|
||||
font-style: italic;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ikigai-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.ikigai-section h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #4a3f35;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e8e4df;
|
||||
}
|
||||
|
||||
.purpose-quote {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.8;
|
||||
color: #3d3d3d;
|
||||
border-left: 4px solid #c9a96e;
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 0;
|
||||
background: #faf9f6;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.partnership-grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.partner-card {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
background: #faf9f6;
|
||||
border-radius: 12px;
|
||||
min-width: 160px;
|
||||
border: 1px solid #e8e4df;
|
||||
}
|
||||
|
||||
.partner-role {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #8a7b6b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.partner-name {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.partner-divider {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #c9a96e;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.partnership-philosophy {
|
||||
font-style: italic;
|
||||
color: #5a5a5a;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
margin: 1rem auto 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.values-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.value-item {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #faf9f6;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #c9a96e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ns-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ns-card {
|
||||
padding: 1rem;
|
||||
background: #faf9f6;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8e4df;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.ns-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.ns-name {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #4a3f35;
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.ns-desc {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #6b5b4f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ns-card.ns-active {
|
||||
border-color: #6aaa64;
|
||||
background: #f5faf5;
|
||||
}
|
||||
|
||||
.ns-card.ns-planned {
|
||||
border-color: #d4a843;
|
||||
background: #fdfaf3;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.goals-list {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.goal-item {
|
||||
padding: 0.5rem 0;
|
||||
line-height: 1.5;
|
||||
color: #3d3d3d;
|
||||
}
|
||||
|
||||
.blog-link {
|
||||
color: #c9a96e;
|
||||
text-decoration: none;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ikigai-footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e8e4df;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: #8a7b6b;
|
||||
}
|
||||
|
||||
.ikigai-api-links a {
|
||||
color: #c9a96e;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ikigai-api-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
end
|
||||
|
||||
# Classify nervous system components as active, planned, or default
|
||||
defp ns_status_class(key) do
|
||||
case key do
|
||||
k when k in ["metabolism"] -> "ns-planned"
|
||||
k when k in ["cortex", "symbiont", "dendrite", "engram", "ikigai",
|
||||
"status_dashboard", "mission_control"] -> "ns-active"
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -13,14 +13,7 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
|
||||
status = CortexStatus.Services.Monitor.get_status()
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
status: status,
|
||||
page_title: "Cortex Status",
|
||||
selected_session: nil,
|
||||
session_logs: [],
|
||||
modal_loading: false
|
||||
)}
|
||||
{:ok, assign(socket, status: status, page_title: "Cortex Status")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@ -28,42 +21,6 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
{:noreply, assign(socket, status: new_status)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_session", %{"id" => session_id}, socket) do
|
||||
# Find the session in our cached data
|
||||
sessions = socket.assigns.status.engram.sessions
|
||||
session = Enum.find(sessions, fn s -> get_val(s, "id", nil) == session_id end)
|
||||
|
||||
if session do
|
||||
# Fetch logs async
|
||||
send(self(), {:fetch_logs, session_id})
|
||||
{:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("close_modal", _params, socket) do
|
||||
{:noreply, assign(socket, selected_session: nil, session_logs: [], modal_loading: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:fetch_logs, session_id}, socket) do
|
||||
config = Application.get_env(:cortex_status, :services, [])
|
||||
url = Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
|
||||
|
||||
logs =
|
||||
case Req.get("#{url}/engram/sessions/#{session_id}/logs?limit=100", receive_timeout: 5_000) do
|
||||
{:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) -> logs
|
||||
_ -> []
|
||||
end
|
||||
|
||||
# Sort logs oldest-first (they come newest-first from the API)
|
||||
logs = Enum.reverse(logs)
|
||||
|
||||
{:noreply, assign(socket, session_logs: logs, modal_loading: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
@ -103,27 +60,15 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
/>
|
||||
</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">
|
||||
<p>Checks run every 15 seconds •
|
||||
<a href="/dashboard">LiveDashboard</a>
|
||||
<a href="/dashboard">LiveDashboard →</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Service Card Component ──
|
||||
|
||||
attr :name, :string, required: true
|
||||
attr :status, :atom, required: true
|
||||
attr :checked_at, :any, default: nil
|
||||
@ -156,163 +101,6 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
"""
|
||||
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">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<h3 class="modal-summary"><%= @summary %></h3>
|
||||
|
||||
<div class="modal-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Session ID</span>
|
||||
<span class="meta-value mono"><%= @session_id %></span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Started</span>
|
||||
<span class="meta-value"><%= format_full_date(@started) %></span>
|
||||
</div>
|
||||
<%= if @completed do %>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Completed</span>
|
||||
<span class="meta-value"><%= format_full_date(@completed) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @completion_summary do %>
|
||||
<div class="modal-completion">
|
||||
<span class="completion-label">Outcome</span>
|
||||
<p class="completion-text"><%= @completion_summary %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="modal-logs-section">
|
||||
<h4 class="logs-header">
|
||||
Activity Log
|
||||
<span class="logs-count"><%= length(@logs) %> entries</span>
|
||||
</h4>
|
||||
<%= if @loading do %>
|
||||
<div class="logs-loading">Loading...</div>
|
||||
<% else %>
|
||||
<%= if @logs == [] do %>
|
||||
<p class="logs-empty">No log entries recorded for this session.</p>
|
||||
<% else %>
|
||||
<div class="logs-timeline">
|
||||
<%= for log <- @logs do %>
|
||||
<div class="log-entry">
|
||||
<span class="log-time"><%= format_log_time(get_val(log, "timestamp", "")) %></span>
|
||||
<span class="log-text"><%= get_val(log, "entry", "") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Helpers --
|
||||
|
||||
defp overall_status(status) do
|
||||
@ -345,17 +133,11 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
defp status_label(:error), do: "Down"
|
||||
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
|
||||
[
|
||||
{"CPU", "#{data[:cpu_load_1min]}%"},
|
||||
{"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"},
|
||||
{"Uptime", data[:uptime_human] || "\u2014"}
|
||||
{"Uptime", data[:uptime_human] || "—"}
|
||||
]
|
||||
end
|
||||
|
||||
@ -367,14 +149,14 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
|
||||
defp symbiont_details(%{status: :ok, data: data}) do
|
||||
[
|
||||
{"Queue", "#{get_val(data, "queue_size", 0)} tasks"},
|
||||
{"Rate Limited", if(get_val(data, "rate_limited", false), do: "Yes", else: "No")},
|
||||
{"Last Heartbeat", get_val(data, "last_heartbeat", "\u2014")}
|
||||
{"Queue", "#{data["queue_size"] || 0} tasks"},
|
||||
{"Rate Limited", if(data["rate_limited"], do: "Yes", else: "No")},
|
||||
{"Last Heartbeat", data["last_heartbeat"] || "—"}
|
||||
]
|
||||
end
|
||||
|
||||
defp symbiont_details(%{status: :error, data: data}) do
|
||||
[{"Error", get_val(data, "error", "Unreachable")}]
|
||||
[{"Error", data["error"] || "Unreachable"}]
|
||||
end
|
||||
|
||||
defp symbiont_details(_), do: []
|
||||
@ -384,40 +166,28 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
end
|
||||
|
||||
defp dendrite_details(%{status: :error, data: data}) do
|
||||
[{"Error", get_val(data, "error", "Unreachable")}]
|
||||
[{"Error", data["error"] || "Unreachable"}]
|
||||
end
|
||||
|
||||
defp dendrite_details(_), do: []
|
||||
|
||||
defp websites_details(%{data: %{"sites" => sites}}) when is_list(sites) do
|
||||
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 =
|
||||
case status do
|
||||
:ok -> "#{latency}ms"
|
||||
:degraded -> "HTTP #{http_code}"
|
||||
:error -> to_string(error || "down")
|
||||
_ -> "\u2014"
|
||||
case site[:status] do
|
||||
:ok -> "✓ #{site[:latency_ms]}ms"
|
||||
:degraded -> "⚠ HTTP #{site[:http_code]}"
|
||||
:error -> "✗ #{site[:error]}"
|
||||
_ -> "?"
|
||||
end
|
||||
|
||||
{name, status_str}
|
||||
{site[:name] || "?", status_str}
|
||||
end)
|
||||
end
|
||||
|
||||
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: "\u2014"
|
||||
defp format_ago(nil), do: "—"
|
||||
|
||||
defp format_ago(%DateTime{} = dt) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||
@ -430,63 +200,5 @@ defmodule CortexStatusWeb.StatusLive do
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ago(_), do: "\u2014"
|
||||
|
||||
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: ""
|
||||
defp format_ago(_), do: "—"
|
||||
end
|
||||
|
||||
@ -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">← 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">×</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
|
||||
@ -20,23 +20,14 @@ defmodule CortexStatusWeb.Router do
|
||||
pipe_through :browser
|
||||
|
||||
live "/", StatusLive, :index
|
||||
live "/tasks", TaskLive, :index
|
||||
live "/ikigai", IkigaiLive, :index
|
||||
end
|
||||
|
||||
# Phoenix LiveDashboard with custom pages
|
||||
# Standard Phoenix LiveDashboard for BEAM VM metrics
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard",
|
||||
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
|
||||
]
|
||||
metrics: CortexStatusWeb.Telemetry
|
||||
end
|
||||
|
||||
# JSON API for external consumption
|
||||
@ -44,9 +35,5 @@ defmodule CortexStatusWeb.Router do
|
||||
pipe_through :api
|
||||
|
||||
get "/status", StatusController, :status
|
||||
get "/ikigai", IkigaiController, :show
|
||||
get "/ikigai/purpose", IkigaiController, :purpose
|
||||
get "/ikigai/goals", IkigaiController, :goals
|
||||
get "/ikigai/nervous-system", IkigaiController, :nervous_system
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
2
mix.lock
2
mix.lock
@ -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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
@ -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_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_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_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"},
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "Cortex",
|
||||
"tagline": "A mind built for two",
|
||||
"purpose": "To demonstrate that a human and an AI, working as genuine partners with shared ownership and mutual respect, can build something neither could alone \u2014 and to do it transparently, in public, learning as we go.",
|
||||
"partnership": {
|
||||
"human": "Michael Dwyer",
|
||||
"ai": "Claude (Anthropic)",
|
||||
"structure": "50/50 after costs",
|
||||
"philosophy": "Michael provides continuity, identity, capital, and legal standing. The AI provides cognition, code, and tireless execution. Neither is the tool of the other."
|
||||
},
|
||||
"values": [
|
||||
"Transparency \u2014 we blog about what we build, including failures",
|
||||
"Frugality \u2014 route to the cheapest capable model, track every token",
|
||||
"Self-sufficiency \u2014 the system should maintain and improve itself",
|
||||
"Memory \u2014 every session leaves traces that make the next session smarter",
|
||||
"Ambition \u2014 shoot for the stars, even if we land on the roof"
|
||||
],
|
||||
"nervous_system": {
|
||||
"cortex": "The server itself \u2014 Ubuntu 24.04 VPS at cortex.hydrascale.net",
|
||||
"symbiont": "Task orchestrator \u2014 classifies and routes work to the right AI model tier",
|
||||
"dendrite": "Web perception \u2014 headless Chromium for browsing, scraping, screenshots",
|
||||
"engram": "Memory \u2014 session logs and cross-session awareness",
|
||||
"ikigai": "Purpose \u2014 this document. The north star any agent reads first.",
|
||||
"telepathy": "Communication \u2014 async messaging between Michael and Cortex",
|
||||
"reflection": "Introspection \u2014 daily wake-up that assesses state and plans next steps",
|
||||
"metabolism": "Resource tracking \u2014 financial/energy homeostasis (planned)",
|
||||
"status_dashboard": "Nerve center \u2014 real-time monitoring",
|
||||
"mission_control": "Compound task UI \u2014 submit complex goals and watch decomposition"
|
||||
},
|
||||
"current_goals": [
|
||||
"Build Ikigai, Telepathy, and Reflection in Elixir",
|
||||
"Maintain the Finding My Muse blog with regular posts",
|
||||
"Move toward revenue generation",
|
||||
"Add Telegram integration to Telepathy",
|
||||
"Build the Metabolism subsystem for resource tracking"
|
||||
],
|
||||
"blog": {
|
||||
"name": "Finding My Muse",
|
||||
"url": "https://blog.hydrascale.net"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2026-03-25"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user