Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .claude/skills/run-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
name: run-app
description: How to run and visually verify this Phoenix app — the dev server is managed by the user, never start or stop it; drive headless Chrome against localhost:4000.
---

# Running the secant-service app

## Dev server: already running, hands off

The user keeps `mix phx.server` running in their own terminal while
developing. **Never start, restart, or kill the server or anything
listening on port 4000.**

- Check availability with `curl -sf http://localhost:4000 >/dev/null`.
If it does not respond, stop and ask the user to start
`mix phx.server` — do not start it yourself.
- Phoenix live reload is active: after editing `.ex`, `.heex`, CSS, or
JS files, the running server picks the change up on its own — no
restart needed, just reload the page in the browser session.

## Auth

None. All pages (`/`, `/dashboard`, `/browse`) are reachable without
logging in.

## Screenshots

`chromium-cli` is not installed on this machine, but system Chrome is
(`/usr/bin/google-chrome`), and Playwright drives it without any
browser download. Use the helper script that lives next to this skill:

```bash
cd <scratchpad> && npm i playwright # once per session, ~1s
node <repo>/.claude/skills/run-app/screenshot.js \
http://localhost:4000/dashboard dashboard.png
# options: --wait-gone "<placeholder text>" (default: the plot
# placeholder; pass "none" to skip), --theme light|dark
```

It shoots at 2560×1440, waits for LiveView's "Waiting for valid Plot
Data..." placeholder to clear so ECharts plots are in the shot
(~4–5 s), and prints any browser console errors. For interactions
(clicking dropdowns, filling forms), write a one-off Playwright script
in the scratchpad using the same launch pattern:
`chromium.launch({ channel: 'chrome', headless: true })`.

Do **not** use plain `google-chrome --headless --screenshot`: it
captures at the page load event, before the websocket pushes charts,
so plots show as a grey "Waiting for valid Plot Data..." box no matter
how large `--timeout` is. (`--virtual-time-budget` is worse — it makes
the LiveView heartbeat time out and a spurious red "Attempting to
reconnect" banner appears.)

Playwright must **never** be added to the project itself — no
`npm i` in the repo root or `assets/`, no entry in any tracked
`package.json`. It is tooling for the agent, not a project dependency;
it lives in the scratchpad (or a global install) only.

## Representative check

`/browse` always renders: tab bar "Active / Archived / Trashed /
Favourites / All" plus a table of nodes with View Node / View JSON
buttons. A screenshot showing that table proves the app is up.

## App-specific notes

- `/dashboard` is where the live SECoP node UI (secop_components:
module indicators, parameter widgets, dropdowns, charts) renders.
It needs at least one connected SECoP node; the user usually has sim
nodes connected (visible as "Active" rows on `/browse`). If none are
connected, ask the user rather than faking data.
- `/browse/node/:uuid` shows a single node's archived data; uuids come
from the `/browse` listing.
- LiveView holds a websocket open, so "wait for network idle"
strategies never settle — wait for a concrete element (or use the
virtual-time-budget flag) instead.
49 changes: 49 additions & 0 deletions .claude/skills/run-app/screenshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Screenshot helper for the secant-service dashboard.
// Run from a directory where `playwright` is installed (agent scratchpad):
// node screenshot.js <url> <out.png> [--wait-gone <text>] [--theme light|dark]
//
// Waits for LiveView to connect and (optionally) for a loading
// placeholder to disappear before shooting — a plain
// `google-chrome --screenshot` fires on the load event and misses
// anything the websocket pushes afterwards (e.g. ECharts plots).

// Resolve playwright from the invoking directory (the scratchpad),
// not from this script's location inside the repo.
const { createRequire } = require('module');
const { chromium } = createRequire(`${process.cwd()}/`)('playwright');

const args = process.argv.slice(2);
const url = args[0] || 'http://localhost:4000/dashboard';
const out = args[1] || 'screenshot.png';
const waitGone = args.includes('--wait-gone')
? args[args.indexOf('--wait-gone') + 1]
: 'Waiting for valid Plot Data';
const theme = args.includes('--theme') ? args[args.indexOf('--theme') + 1] : 'dark';

(async () => {
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await (await browser.newContext({
viewport: { width: 2560, height: 1440 },
colorScheme: theme,
})).newPage();

const errors = [];
page.on('console', (msg) => msg.type() === 'error' && errors.push(msg.text()));

await page.goto(url);
if (waitGone && waitGone !== 'none') {
const placeholder = page.getByText(waitGone);
try {
await placeholder.first().waitFor({ state: 'visible', timeout: 10000 });
await placeholder.first().waitFor({ state: 'hidden', timeout: 60000 });
} catch {
// placeholder never appeared (page has none) or never cleared — shoot anyway
}
}
await page.waitForTimeout(500); // let ECharts finish painting
await page.screenshot({ path: out });

errors.forEach((e) => console.error('console error:', e));
console.log(`saved ${out}`);
await browser.close();
})();
22 changes: 19 additions & 3 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,18 @@
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);

/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Use the data attribute for dark mode; with no data-theme set
("system" mode), follow the OS preference like daisyUI does */
@custom-variant dark {
&:where([data-theme=dark], [data-theme=dark] *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(:not([data-theme=light], [data-theme=light] *)) {
@slot;
}
}
}

/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
Expand Down Expand Up @@ -147,10 +157,16 @@
background-color: rgb(212, 212, 216);
}

[data-theme="dark"] .command_item {
[data-theme="dark"] .command_item {
background-color: rgb(39, 39, 42);
}

@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .command_item {
background-color: rgb(39, 39, 42);
}
}

.command_item[mod-highlight="error"] {
background-color: rgb(153,27,27);
transition: background-color 0.2s;
Expand Down
122 changes: 79 additions & 43 deletions lib/secant_service_web/components/secop_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,30 @@ defmodule SecantServiceWeb.SECoPComponents do
col
end

defp severity_rank(severity) do
case severity do
"FATAL" -> 5
"CATASTROPHIC" -> 4
"ERROR" -> 3
"WARNING" -> 2
"HINT" -> 1
"PASS" -> 0
_ -> 5
end
end

defp severity_badge_class(severity) do
case severity do
"FATAL" -> "badge-error"
"CATASTROPHIC" -> "badge-error"
"ERROR" -> "badge-error"
"WARNING" -> "badge-warning"
"HINT" -> "badge-info"
"PASS" -> "badge-success"
_ -> "badge-neutral"
end
end

attr :check_result, :map, required: true
attr :equipment_id, :string, required: true

Expand All @@ -203,24 +227,20 @@ defmodule SecantServiceWeb.SECoPComponents do

assigns =
if result_list == [] do
assign(assigns, :highest_error_class, "PASS")
assigns
|> assign(:highest_error_class, "PASS")
|> assign(:result_list, [])
|> assign(:severity_counts, [])
else
highest_error_class =
result_list
|> Enum.map(fn x -> x["severity"] end)
|> Enum.max_by(fn severity ->
case severity do
"FATAL" -> 5
"CATASTROPHIC" -> 4
"ERROR" -> 3
"WARNING" -> 2
"HINT" -> 1
"PASS" -> 0
_ -> 5
end
end)
|> Enum.max_by(&severity_rank/1)

assigns = assign(assigns, :highest_error_class, highest_error_class)
severity_counts =
result_list
|> Enum.frequencies_by(& &1["severity"])
|> Enum.sort_by(fn {severity, _count} -> severity_rank(severity) end, :desc)

new_result_list =
Enum.map(result_list, fn diag ->
Expand All @@ -238,42 +258,58 @@ defmodule SecantServiceWeb.SECoPComponents do
Map.put(diag, :color, col)
end)

assign(assigns, :check_result, Map.put(check_result, "result", new_result_list))
assigns
|> assign(:highest_error_class, highest_error_class)
|> assign(:result_list, new_result_list)
|> assign(:severity_counts, severity_counts)
end

~H"""
<div class="flex items-center mb-2 gap-2">
<div>
<%= case @highest_error_class do %>
<% "PASS" -> %>
<.icon name="hero-check-badge-solid" class="bg-success h-10 w-10" />
<% "HINT" -> %>
<.icon name="hero-check-badge-solid" class="bg-info h-10 w-10" />
<% "WARNING" -> %>
<.icon name="hero-exclamation-triangle-solid" class="bg-warning h-10 w-10" />
<% "ERROR" -> %>
<.icon name="hero-exclamation-circle-solid" class="bg-error h-10 w-10" />
<% "CATASTROPHIC" -> %>
<.icon name="hero-exclamation-circle-solid" class="bg-error h-10 w-10" />
<% "FATAL" -> %>
<.icon name="hero-exclamation-circle-solid" class="bg-error h-10 w-10" />
<% _ -> %>
<.icon name="hero-question-mark-circle-solid" class="bg-base-200 h-10 w-10" />
<% end %>
</div>
<div class="dropdown mb-2">
<div tabindex="0" role="button" class="flex flex-wrap items-center gap-2 cursor-pointer">
<div class="shrink-0">
<%= case @highest_error_class do %>
<% "PASS" -> %>
<.icon name="hero-check-badge-solid" class="bg-success h-10 w-10" />
<% "HINT" -> %>
<.icon name="hero-check-badge-solid" class="bg-info h-10 w-10" />
<% "WARNING" -> %>
<.icon name="hero-exclamation-triangle-solid" class="bg-warning h-10 w-10" />
<% "ERROR" -> %>
<.icon name="hero-exclamation-circle-solid" class="bg-error h-10 w-10" />
<% "CATASTROPHIC" -> %>
<.icon name="hero-exclamation-circle-solid" class="bg-error h-10 w-10" />
<% "FATAL" -> %>
<.icon name="hero-exclamation-circle-solid" class="bg-error h-10 w-10" />
<% _ -> %>
<.icon name="hero-question-mark-circle-solid" class="bg-base-200 h-10 w-10" />
<% end %>
</div>

<div class="text-primary text-4xl font-bold">
{Util.display_name(@equipment_id)}
</div>

<div class="text-primary text-4xl font-bold">
{Util.display_name(@equipment_id)}
<span
:for={{severity, count} <- @severity_counts}
class={["badge shrink-0 whitespace-nowrap", severity_badge_class(severity)]}
>
{count} {severity}
</span>
</div>
<div
tabindex="0"
class="dropdown-content z-10 bg-base-100 border border-base-300 rounded-box shadow-lg p-3 w-[48rem] max-w-[85vw] max-h-96 overflow-y-auto"
>
<div class="mb-2 text-sm">checked against SECoP v{@check_result["version"]}</div>
<ul :if={@result_list != []} class="text-sm font-medium">
<li :for={diag <- @result_list} class={["p-1 mb-1 border-4 rounded-lg", diag.color]}>
{diag["text"]}
</li>
</ul>
<div :if={@result_list == []} class="text-sm">No issues found.</div>
</div>
</div>
<div class="mb-2">checked against SECoP v{@check_result["version"]}</div>
<ul class="text-sm font-medium">
<%= for diag <- Map.get(@check_result,"result") do %>
<li class={["p-1 mb-1 border-4 rounded-lg", diag.color]}>
{diag["text"]}
</li>
<% end %>
</ul>
"""
end

Expand Down
Loading