feat: alerts rule builder + destinations UI, portfolio charts + accuracy ledger
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-15 16:46:17 +07:00
parent 94a7f1dfd9
commit 5927eec465
2 changed files with 125 additions and 80 deletions
+59 -19
View File
@@ -1,26 +1,44 @@
import { createResource, createSignal, For } from "solid-js";
import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api";
import { EmptyState, Skeleton } from "../components/ui";
const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"];
const OPS = [">=", "<=", ">", "<", "=="];
type Cond = { field: string; op: string; value: number };
export default function Alerts() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
const [name, setName] = createSignal("accumulation watcher");
const [rule, setRule] = createSignal('{"all": true}');
const [field, setField] = createSignal("score");
const [op, setOp] = createSignal(">=");
const [value, setValue] = createSignal(60);
const [conds, setConds] = createSignal<Cond[]>([]);
const [channels, setChannels] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [dkind, setDkind] = createSignal("telegram");
const [dlabel, setDlabel] = createSignal("");
const [dbot, setDbot] = createSignal("");
const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal("");
const rulePreview = () => JSON.stringify(conds().length ? { all: conds() } : { all: true });
function addCond() {
setConds((p) => [...p, { field: field(), op: op(), value: value() }]);
}
async function create() {
setBusy(true);
try {
await api.createAlert({
name: name(),
rule: JSON.parse(rule()),
rule: JSON.parse(rulePreview()),
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
setConds([]);
refetch();
} finally { setBusy(false); }
}
async function del(id: number) { await api.deleteAlert(id); refetch(); }
async function addDest() {
@@ -41,10 +59,11 @@ export default function Alerts() {
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
return (
<div>
<h1>Alerts</h1>
<div class="fs-page-head"><h1>Alerts</h1><p class="fs-muted">Rules evaluated over snapshots each cycle.</p></div>
<div class="fs-card">
<h3>Push destinations (per login)</h3>
<p class="fs-muted">Secrets stay server-side — the list never shows tokens. Delivery uses your enabled destinations; server env is fallback.</p>
<p class="fs-muted">Secrets stay server-side — the list never shows tokens.</p>
<div class="fs-row">
<select class="fs-input" value={dkind()} onChange={(e) => setDkind(e.currentTarget.value)}>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
@@ -52,38 +71,59 @@ export default function Alerts() {
<input class="fs-input" placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} style={{ width: "160px" }} />
{dkind() === "telegram" ? (
<>
<input class="fs-input" placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} style={{ width: "220px" }} />
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "140px" }} />
<input class="fs-input" placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} style={{ width: "200px" }} />
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "120px" }} />
</>
) : (
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ width: "300px" }} />
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ flex: 1 }} />
)}
<button class="fs-btn" onClick={addDest}>Add destination</button>
{derr() ? <p class="fs-muted">{derr()}</p> : null}
</div>
{derr() ? <p style={{ color: "var(--fs-avoid)" }}>{derr()}</p> : null}
<ul>
{(dests()?.destinations || []).map((d) => (
<li>{d.kind} — {d.label || `#${d.id}`} <span class="fs-muted">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
<li><span class={`fs-badge ${d.enabled ? "buy" : "info"}`}>{d.kind}</span> {d.label || `#${d.id}`} <span class="fs-muted">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
<button class="fs-btn ghost" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</button>
<button class="fs-btn ghost" onClick={() => delDest(d.id)}>del</button>
<button class="fs-btn danger" onClick={() => delDest(d.id)}>Delete</button>
</li>
))}
</ul>
</div>
<div class="fs-card">
<div class="fs-card" style={{ "margin-top": "14px" }}>
<h3>New rule</h3>
<input class="fs-input" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
<input class="fs-input" value={rule()} onInput={(e) => setRule(e.currentTarget.value)} style={{ width: "280px" }} />
<input class="fs-input" placeholder="Telegram/Discord channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "300px" }} />
<button class="fs-btn" onClick={create}>Create</button>
<p class="fs-muted">Delivery: your enabled destinations above (server env fallback). Empty = record only.</p>
<div class="fs-row">
<input class="fs-input" placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} style={{ flex: 1 }} />
</div>
<div class="fs-grid">
<div class="fs-row" style={{ "margin-top": "10px" }}>
<select class="fs-input" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
</select>
<select class="fs-input" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
</select>
<input class="fs-input" type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} style={{ width: "100px" }} />
<button class="fs-btn ghost" onClick={addCond}>+ condition</button>
</div>
<Show when={conds().length}>
<ul><For each={conds()}>{(c, i) => <li class="fs-muted">{c.field} {c.op} {c.value} <button class="fs-btn ghost" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</button></li>}</For></ul>
</Show>
<p class="fs-muted">Rule JSON: <code>{rulePreview()}</code></p>
<div class="fs-row">
<input class="fs-input" placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ flex: 1 }} />
<button class="fs-btn" onClick={create} disabled={busy()}>Create rule</button>
</div>
</div>
<div class="fs-grid" style={{ "margin-top": "14px" }}>
<div class="fs-card"><h3>Rules</h3>
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn ghost" onClick={() => del(a.id)}>del</button></li>}</For></ul>
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn danger" onClick={() => del(a.id)}>Delete</button></li>}</For></ul>
</Show>
</div>
<div class="fs-card"><h3>Event history</h3>
<button class="fs-btn ghost" onClick={() => refetchEv()}>Refresh</button>
<Show when={(events() || []).length} fallback={<p class="fs-muted">No events yet.</p>}>
<ul><For each={events() || []}>{(e) => <li><strong>{String(e.ticker)}</strong> — {String(e.message)}</li>}</For></ul>
</Show>
</div>
</div>
</div>
+14 -9
View File
@@ -1,7 +1,8 @@
import { createResource, For } from "solid-js";
import { createResource, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Bar } from "solid-chartjs";
import { Chart, registerables } from "chart.js";
import { StatCard, Skeleton } from "../components/ui";
Chart.register(...registerables);
export default function Portfolio() {
@@ -9,23 +10,24 @@ export default function Portfolio() {
const [acc] = createResource(() => api.accuracy());
const chartData = () => ({
labels: (risk()?.concentration || []).map((c) => c.ticker),
datasets: [{ label: "Weight", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
datasets: [{ label: "Weight %", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
});
return (
<div>
<h1>Portfolio Risk + Accuracy Ledger</h1>
<div class="fs-page-head"><h1>Portfolio Risk + Accuracy Ledger</h1><p class="fs-muted">Computed from your watchlist.</p></div>
<Show when={!risk.loading && !acc.loading} fallback={<Skeleton rows={4} />}>
<div class="fs-grid">
<div class="fs-card">
<h3>Concentration</h3>
<Show when={(risk()?.concentration || []).length} fallback={<p class="fs-muted">Add tickers to your watchlist to see concentration.</p>}>
<Bar data={chartData()} options={{ responsive: true }} />
<ul><For each={risk()?.warnings || []}>{(w) => <li>{w}</li>}</For></ul>
</Show>
<For each={risk()?.warnings || []}>{(w) => <p>⚠️ {w}</p>}</For>
</div>
<div class="fs-card">
<h3>Beta vs index</h3>
<p style={{ "font-size": "24px" }}>{risk()?.beta ?? "…"}</p>
<h3>Correlation heatmap</h3>
<StatCard title="Beta vs index" value={risk() ? String(risk()!.beta) : "…"}>
<h3 style={{ "margin-top": "12px" }}>Correlation heatmap</h3>
<CorrHeatmap matrix={risk()?.correlation || {}} />
</div>
</StatCard>
<div class="fs-card">
<h3>Agent accuracy</h3>
<table class="fs-table">
@@ -38,6 +40,7 @@ export default function Portfolio() {
</table>
</div>
</div>
</Show>
</div>
);
}
@@ -50,6 +53,7 @@ function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> })
return bg;
};
return (
<Show when={keys().length} fallback={<p class="fs-muted">No correlation data yet.</p>}>
<div class="fs-scroll"><table class="fs-table">
<thead><tr><th></th><For each={keys()}>{(k) => <th>{k}</th>}</For></tr></thead>
<tbody>
@@ -68,5 +72,6 @@ function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> })
</For>
</tbody>
</table></div>
</Show>
);
}