feat(cxwebapp): comprehensive pane enhancements
Some checks are pending
build-and-push / image (push) Waiting to run

- All 14 panes rewritten with rich controls: KPIs, sparklines, meter
  gauges, presets, history sidebars, search/filter, bulk actions,
  schema-driven forms, copy buttons, auto-refresh.
- Backend /api/system enriched with cpu_count, rss_bytes, mem_total,
  mem_used_pct, loadavg[], user/system CPU times, max_rss_kb.
- CSS additions: .meter/.gauge, .seg, .chip(s), .spark, .slider, .dl,
  .link-card, details.card-d plus utility classes.
- ui.js helpers: copyToClipboard, withCopy/bindCopyButtons, sparkline,
  meterRow, fmtBytes/fmtNum, historyStore, inputFromSchema,
  collectSchemaForm.
- Files pane added; iCloud duplicate '*2' files removed.
This commit is contained in:
CxAI Agent 2026-05-17 09:36:19 -05:00
parent d057e09fa2
commit 75153b7fe9
25 changed files with 2540 additions and 869 deletions

View File

@ -1,58 +0,0 @@
#include "crow.h"
#include "routes.h"
#include <fstream>
#include <sstream>
namespace routes {
namespace {
std::string read_file(const std::string& path) {
std::ifstream ifs(path);
if (!ifs.is_open()) return "";
std::ostringstream ss;
ss << ifs.rdbuf();
return ss.str();
}
} // anonymous namespace
void register_pages(crow::SimpleApp& app) {
// Home page — serves static HTML
CROW_ROUTE(app, "/")
([]() {
std::string html = read_file("static/index.html");
if (html.empty()) {
return crow::response(500, "index.html not found");
}
auto resp = crow::response(html);
resp.add_header("Content-Type", "text/html; charset=utf-8");
return resp;
});
// Static file serving for CSS, JS, images
CROW_ROUTE(app, "/static/<path>")
([](const std::string& path) {
std::string full_path = "static/" + path;
std::string content = read_file(full_path);
if (content.empty()) {
return crow::response(404);
}
auto resp = crow::response(content);
// Set content type based on extension
if (path.ends_with(".css")) resp.add_header("Content-Type", "text/css");
else if (path.ends_with(".js")) resp.add_header("Content-Type", "application/javascript");
else if (path.ends_with(".html")) resp.add_header("Content-Type", "text/html");
else if (path.ends_with(".json")) resp.add_header("Content-Type", "application/json");
else if (path.ends_with(".png")) resp.add_header("Content-Type", "image/png");
else if (path.ends_with(".svg")) resp.add_header("Content-Type", "image/svg+xml");
else resp.add_header("Content-Type", "application/octet-stream");
return resp;
});
}
} // namespace routes

View File

@ -1,254 +0,0 @@
// Reverse-proxy routes for the 4 sidecar services. Uses cpp-httplib as the
// upstream client (single-header, no extra system packages).
//
// Upstream URLs are read from env at startup; missing envs make that prefix
// return 503 so the SPA can render a graceful "service unavailable" state.
#define CPPHTTPLIB_OPENSSL_SUPPORT 0
#include <httplib.h>
#include "crow.h"
#include "routes.h"
#include <cstdlib>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace routes {
namespace {
struct Upstream {
std::string scheme; // "http" or "https"
std::string host;
int port = 0;
};
std::optional<Upstream> parse_upstream(const std::string& url) {
// Expect: http(s)://host[:port]
if (url.empty()) return std::nullopt;
Upstream up;
std::string rest;
if (url.rfind("https://", 0) == 0) {
up.scheme = "https";
rest = url.substr(8);
up.port = 443;
} else if (url.rfind("http://", 0) == 0) {
up.scheme = "http";
rest = url.substr(7);
up.port = 80;
} else {
return std::nullopt;
}
auto slash = rest.find('/');
std::string hostport = slash == std::string::npos ? rest : rest.substr(0, slash);
auto colon = hostport.find(':');
if (colon == std::string::npos) {
up.host = hostport;
} else {
up.host = hostport.substr(0, colon);
try { up.port = std::stoi(hostport.substr(colon + 1)); } catch (...) {}
}
if (up.host.empty()) return std::nullopt;
return up;
}
std::optional<Upstream> env_upstream(const char* var) {
const char* v = std::getenv(var);
if (!v || !*v) return std::nullopt;
return parse_upstream(std::string(v));
}
const std::unordered_set<std::string>& hop_by_hop() {
static const std::unordered_set<std::string> h = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host",
"content-length",
};
return h;
}
std::string to_lower(std::string s) {
for (auto& c : s) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
return s;
}
crow::response service_unavailable(const std::string& name) {
crow::json::wvalue j;
j["error"] = "upstream_unavailable";
j["service"] = name;
j["hint"] = "set CXAI_" + name + "_UPSTREAM env var (e.g. http://cxai-" + to_lower(name) + ":8101)";
auto resp = crow::response(503, j);
resp.add_header("Content-Type", "application/json");
return resp;
}
// Strip the "/api/<service>" prefix and return the remainder (always starts with '/').
std::string strip_prefix(const std::string& url, const std::string& prefix) {
if (url.size() < prefix.size() || url.compare(0, prefix.size(), prefix) != 0) {
return "/";
}
std::string rest = url.substr(prefix.size());
if (rest.empty() || rest.front() != '/') rest = "/" + rest;
return rest;
}
// Build an httplib::Client per request (cheap; keeps things thread-safe).
httplib::Client make_client(const Upstream& up) {
httplib::Client cli(up.host, up.port);
cli.set_connection_timeout(5, 0);
cli.set_read_timeout(120, 0);
cli.set_write_timeout(30, 0);
return cli;
}
httplib::Headers forward_headers(const crow::request& req) {
httplib::Headers h;
for (const auto& kv : req.headers) {
std::string k = to_lower(kv.first);
if (hop_by_hop().count(k)) continue;
h.emplace(kv.first, kv.second);
}
return h;
}
crow::response from_httplib(const httplib::Result& r, const std::string& service) {
if (!r) {
crow::json::wvalue j;
j["error"] = "upstream_error";
j["service"] = service;
j["detail"] = httplib::to_string(r.error());
auto resp = crow::response(502, j);
resp.add_header("Content-Type", "application/json");
return resp;
}
crow::response out(r->status, r->body);
for (const auto& kv : r->headers) {
std::string k = to_lower(kv.first);
if (hop_by_hop().count(k)) continue;
out.add_header(kv.first, kv.second);
}
return out;
}
crow::response forward(const Upstream& up, const std::string& service,
const crow::request& req, const std::string& path) {
auto cli = make_client(up);
auto headers = forward_headers(req);
std::string upstream_path = path;
if (!req.url_params.get(nullptr)) {
// url_params has no direct stringify; reconstruct from raw url if present.
}
// Crow stores the query string inside `req.raw_url`; append it.
auto qpos = req.raw_url.find('?');
if (qpos != std::string::npos) {
upstream_path += req.raw_url.substr(qpos);
}
auto content_type_it = req.headers.find("Content-Type");
std::string content_type = content_type_it != req.headers.end()
? content_type_it->second
: std::string("application/octet-stream");
switch (req.method) {
case crow::HTTPMethod::GET:
return from_httplib(cli.Get(upstream_path.c_str(), headers), service);
case crow::HTTPMethod::DELETE:
return from_httplib(cli.Delete(upstream_path.c_str(), headers), service);
case crow::HTTPMethod::HEAD:
return from_httplib(cli.Head(upstream_path.c_str(), headers), service);
case crow::HTTPMethod::POST:
return from_httplib(
cli.Post(upstream_path.c_str(), headers, req.body, content_type.c_str()),
service);
case crow::HTTPMethod::PUT:
return from_httplib(
cli.Put(upstream_path.c_str(), headers, req.body, content_type.c_str()),
service);
case crow::HTTPMethod::PATCH:
return from_httplib(
cli.Patch(upstream_path.c_str(), headers, req.body, content_type.c_str()),
service);
default:
return crow::response(405, "method not allowed via proxy");
}
}
struct ProxyMount {
std::string service; // "diffusion" / "demand" / "lang" / "slack"
std::string env_var; // "CXAI_DIFFUSION_UPSTREAM" / ...
std::string prefix; // "/api/diffusion"
std::optional<Upstream> upstream;
};
std::vector<ProxyMount>& mounts() {
static std::vector<ProxyMount> m = {
{"diffusion", "CXAI_DIFFUSION_UPSTREAM", "/api/diffusion", std::nullopt},
{"demand", "CXAI_DEMAND_UPSTREAM", "/api/demand", std::nullopt},
{"lang", "CXAI_LANG_UPSTREAM", "/api/lang", std::nullopt},
{"slack", "CXAI_SLACK_UPSTREAM", "/api/slack", std::nullopt},
};
static std::once_flag once;
std::call_once(once, [] {
for (auto& mt : m) mt.upstream = env_upstream(mt.env_var.c_str());
});
return m;
}
void install_one(crow::SimpleApp& app, ProxyMount& mount) {
// Crow `<path>` matches multiple segments; we register one catch-all per
// mount, plus an exact-prefix variant for the bare `/api/<svc>` URL.
static const std::vector<crow::HTTPMethod> methods = {
crow::HTTPMethod::GET, crow::HTTPMethod::POST, crow::HTTPMethod::PUT,
crow::HTTPMethod::DELETE, crow::HTTPMethod::PATCH, crow::HTTPMethod::HEAD,
};
auto handler = [&mount](const crow::request& req) -> crow::response {
if (!mount.upstream) return service_unavailable(mount.service);
std::string path = strip_prefix(req.url, mount.prefix);
return forward(*mount.upstream, mount.service, req, path);
};
// Sub-paths: /api/<svc>/<...>
app.route_dynamic(mount.prefix + "/<path>")
.methods(methods.data(), methods.size())(
[handler](const crow::request& req, const std::string&) {
return handler(req);
});
// Bare prefix (no trailing path)
app.route_dynamic(mount.prefix)
.methods(methods.data(), methods.size())(handler);
}
} // anonymous namespace
void register_proxy(crow::SimpleApp& app) {
// Health-summary endpoint, lists configured proxies.
CROW_ROUTE(app, "/api/services")
([]() {
crow::json::wvalue r;
std::vector<crow::json::wvalue> arr;
for (const auto& m : mounts()) {
crow::json::wvalue item;
item["service"] = m.service;
item["prefix"] = m.prefix;
item["configured"] = static_cast<bool>(m.upstream);
if (m.upstream) {
item["upstream_host"] = m.upstream->host;
item["upstream_port"] = m.upstream->port;
item["upstream_scheme"] = m.upstream->scheme;
}
arr.push_back(std::move(item));
}
r["services"] = std::move(arr);
return r;
});
for (auto& m : mounts()) install_one(app, m);
}
} // namespace routes

View File

@ -33,6 +33,7 @@ struct Upstream {
std::string scheme; std::string scheme;
std::string host; std::string host;
int port = 0; int port = 0;
std::string path_prefix; // e.g. "/rest/v1" — appended before forwarded path.
}; };
std::optional<Upstream> parse_upstream(const std::string& url) { std::optional<Upstream> parse_upstream(const std::string& url) {
@ -52,6 +53,11 @@ std::optional<Upstream> parse_upstream(const std::string& url) {
} }
auto slash = rest.find('/'); auto slash = rest.find('/');
std::string hostport = slash == std::string::npos ? rest : rest.substr(0, slash); std::string hostport = slash == std::string::npos ? rest : rest.substr(0, slash);
if (slash != std::string::npos) {
up.path_prefix = rest.substr(slash);
// Trim trailing slash so concatenation stays clean.
while (up.path_prefix.size() > 1 && up.path_prefix.back() == '/') up.path_prefix.pop_back();
}
auto colon = hostport.find(':'); auto colon = hostport.find(':');
if (colon == std::string::npos) { if (colon == std::string::npos) {
up.host = hostport; up.host = hostport;
@ -127,7 +133,7 @@ crow::response forward(const Upstream& up, const std::string& service,
cli.set_write_timeout(30, 0); cli.set_write_timeout(30, 0);
auto headers = forward_headers(req); auto headers = forward_headers(req);
std::string upstream_path = "/" + tail; std::string upstream_path = up.path_prefix + "/" + tail;
auto qpos = req.raw_url.find('?'); auto qpos = req.raw_url.find('?');
if (qpos != std::string::npos) { if (qpos != std::string::npos) {
upstream_path += req.raw_url.substr(qpos); upstream_path += req.raw_url.substr(qpos);
@ -166,6 +172,8 @@ struct UpstreamSet {
std::optional<Upstream> demand; std::optional<Upstream> demand;
std::optional<Upstream> lang; std::optional<Upstream> lang;
std::optional<Upstream> slack; std::optional<Upstream> slack;
std::optional<Upstream> files;
std::string files_anon_key;
}; };
const UpstreamSet& upstreams() { const UpstreamSet& upstreams() {
static UpstreamSet s; static UpstreamSet s;
@ -175,10 +183,58 @@ const UpstreamSet& upstreams() {
s.demand = env_upstream("CXAI_DEMAND_UPSTREAM"); s.demand = env_upstream("CXAI_DEMAND_UPSTREAM");
s.lang = env_upstream("CXAI_LANG_UPSTREAM"); s.lang = env_upstream("CXAI_LANG_UPSTREAM");
s.slack = env_upstream("CXAI_SLACK_UPSTREAM"); s.slack = env_upstream("CXAI_SLACK_UPSTREAM");
s.files = env_upstream("CXAI_FILES_UPSTREAM");
if (const char* k = std::getenv("FILES_ANON_KEY")) s.files_anon_key = k;
else if (const char* k = std::getenv("CXAI_FILES_ANON_KEY")) s.files_anon_key = k;
}); });
return s; return s;
} }
// Files-specific forwarder: injects PostgREST auth headers from FILES_ANON_KEY.
crow::response forward_files(const Upstream& up, const crow::request& req,
const std::string& tail) {
httplib::Client cli(up.host, up.port);
cli.set_connection_timeout(5, 0);
cli.set_read_timeout(120, 0);
cli.set_write_timeout(30, 0);
auto headers = forward_headers(req);
const auto& key = upstreams().files_anon_key;
if (!key.empty()) {
// PostgREST expects both "apikey" and a bearer token.
headers.erase("apikey");
headers.erase("Authorization");
headers.emplace("apikey", key);
headers.emplace("Authorization", std::string("Bearer ") + key);
}
std::string upstream_path = up.path_prefix + "/" + tail;
auto qpos = req.raw_url.find('?');
if (qpos != std::string::npos) {
upstream_path += req.raw_url.substr(qpos);
}
std::string content_type = "application/json";
auto it = req.headers.find("Content-Type");
if (it != req.headers.end()) content_type = it->second;
switch (req.method) {
case crow::HTTPMethod::Get:
return from_httplib(cli.Get(upstream_path.c_str(), headers), "files");
case crow::HTTPMethod::Post:
return from_httplib(
cli.Post(upstream_path.c_str(), headers, req.body, content_type.c_str()),
"files");
case crow::HTTPMethod::Patch:
return from_httplib(
cli.Patch(upstream_path.c_str(), headers, req.body, content_type.c_str()),
"files");
case crow::HTTPMethod::Delete:
return from_httplib(cli.Delete(upstream_path.c_str(), headers), "files");
case crow::HTTPMethod::Head:
return from_httplib(cli.Head(upstream_path.c_str(), headers), "files");
default:
return crow::response(405, "method not allowed via files proxy");
}
}
#define PROXY_PREFIX(APP, PREFIX, NAME, UPGETTER) \ #define PROXY_PREFIX(APP, PREFIX, NAME, UPGETTER) \
CROW_ROUTE((APP), PREFIX).methods( \ CROW_ROUTE((APP), PREFIX).methods( \
crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, \ crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, \
@ -227,6 +283,7 @@ void register_proxy(crow::SimpleApp& app) {
arr.push_back(add("demand", upstreams().demand, "/api/demand")); arr.push_back(add("demand", upstreams().demand, "/api/demand"));
arr.push_back(add("lang", upstreams().lang, "/api/lang")); arr.push_back(add("lang", upstreams().lang, "/api/lang"));
arr.push_back(add("slack", upstreams().slack, "/api/slack")); arr.push_back(add("slack", upstreams().slack, "/api/slack"));
arr.push_back(add("files", upstreams().files, "/api/files"));
r["services"] = std::move(arr); r["services"] = std::move(arr);
return r; return r;
}); });
@ -235,6 +292,24 @@ void register_proxy(crow::SimpleApp& app) {
PROXY_PREFIX(app, "/api/demand", "demand", up_demand) PROXY_PREFIX(app, "/api/demand", "demand", up_demand)
PROXY_PREFIX(app, "/api/lang", "lang", up_lang) PROXY_PREFIX(app, "/api/lang", "lang", up_lang)
PROXY_PREFIX(app, "/api/slack", "slack", up_slack) PROXY_PREFIX(app, "/api/slack", "slack", up_slack)
// Files: dedicated routes that inject PostgREST auth headers.
CROW_ROUTE(app, "/api/files").methods(
crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Patch,
crow::HTTPMethod::Delete, crow::HTTPMethod::Head)
([](const crow::request& req) {
const auto& up = upstreams().files;
if (!up) return service_unavailable("files");
return forward_files(*up, req, "");
});
CROW_ROUTE(app, "/api/files/<path>").methods(
crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Patch,
crow::HTTPMethod::Delete, crow::HTTPMethod::Head)
([](const crow::request& req, std::string tail) {
const auto& up = upstreams().files;
if (!up) return service_unavailable("files");
return forward_files(*up, req, tail);
});
} }
} // namespace routes } // namespace routes

View File

@ -3,10 +3,19 @@
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <ctime> #include <ctime>
#include <fstream>
#include <sstream>
#include <string> #include <string>
#include <sys/resource.h>
#include <sys/utsname.h> #include <sys/utsname.h>
#include <thread>
#include <unistd.h> #include <unistd.h>
#if defined(__APPLE__)
# include <mach/mach.h>
# include <sys/sysctl.h>
#endif
namespace routes { namespace routes {
namespace { namespace {
@ -21,6 +30,63 @@ long long boot_epoch() {
return boot; return boot;
} }
// Per-process resident memory in bytes, best-effort across Linux/macOS.
long long rss_bytes() {
#if defined(__APPLE__)
mach_task_basic_info info{};
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO,
reinterpret_cast<task_info_t>(&info), &count) == KERN_SUCCESS) {
return static_cast<long long>(info.resident_size);
}
return 0;
#else
std::ifstream f("/proc/self/status");
std::string line;
while (std::getline(f, line)) {
if (line.rfind("VmRSS:", 0) == 0) {
std::istringstream ss(line.substr(6));
long long kb = 0;
ss >> kb;
return kb * 1024;
}
}
return 0;
#endif
}
// 1/5/15-minute load average (returns 0s on platforms without getloadavg).
void load_avg(double out[3]) {
out[0] = out[1] = out[2] = 0.0;
#if defined(_SC_NPROCESSORS_ONLN) || defined(__APPLE__) || defined(__linux__)
double a[3] = {0, 0, 0};
if (getloadavg(a, 3) == 3) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; }
#endif
}
long long total_memory_bytes() {
#if defined(__APPLE__)
int mib[2] = {CTL_HW, HW_MEMSIZE};
int64_t mem = 0;
size_t len = sizeof(mem);
if (sysctl(mib, 2, &mem, &len, nullptr, 0) == 0) {
return static_cast<long long>(mem);
}
return 0;
#else
std::ifstream f("/proc/meminfo");
std::string line;
while (std::getline(f, line)) {
if (line.rfind("MemTotal:", 0) == 0) {
std::istringstream ss(line.substr(9));
long long kb = 0; ss >> kb;
return kb * 1024;
}
}
return 0;
#endif
}
} // anonymous namespace } // anonymous namespace
void register_system(crow::SimpleApp& app) { void register_system(crow::SimpleApp& app) {
@ -54,6 +120,25 @@ void register_system(crow::SimpleApp& app) {
r["pid"] = static_cast<long>(::getpid()); r["pid"] = static_cast<long>(::getpid());
r["uptime_seconds"] = static_cast<long>(std::time(nullptr) - boot_epoch()); r["uptime_seconds"] = static_cast<long>(std::time(nullptr) - boot_epoch());
r["cxx_standard"] = static_cast<int>(__cplusplus); r["cxx_standard"] = static_cast<int>(__cplusplus);
r["cpu_count"] = static_cast<int>(std::thread::hardware_concurrency());
long long rss = rss_bytes();
long long total = total_memory_bytes();
r["rss_bytes"] = static_cast<double>(rss);
r["mem_total"] = static_cast<double>(total);
if (total > 0) r["mem_used_pct"] = static_cast<double>(rss) / static_cast<double>(total) * 100.0;
double la[3]; load_avg(la);
crow::json::wvalue::list lvec;
lvec.emplace_back(la[0]); lvec.emplace_back(la[1]); lvec.emplace_back(la[2]);
r["loadavg"] = std::move(lvec);
struct rusage ru{};
if (getrusage(RUSAGE_SELF, &ru) == 0) {
r["user_cpu_s"] = static_cast<double>(ru.ru_utime.tv_sec) + ru.ru_utime.tv_usec / 1e6;
r["system_cpu_s"] = static_cast<double>(ru.ru_stime.tv_sec) + ru.ru_stime.tv_usec / 1e6;
r["max_rss_kb"] = static_cast<double>(ru.ru_maxrss);
}
return r; return r;
}); });

View File

@ -500,3 +500,258 @@ html:not(.dark) .console { background: #0d1530; color: #d4f0c9; }
.tool-card + .tool-card { margin-top: 8px; } .tool-card + .tool-card { margin-top: 8px; }
.tool-card h4 { margin: 0; font-size: 13.5px; font-weight: 600; font-family: var(--font-mono); } .tool-card h4 { margin: 0; font-size: 13.5px; font-weight: 600; font-family: var(--font-mono); }
.tool-card p { margin: 4px 0 0; color: var(--fg-muted); font-size: 12.5px; } .tool-card p { margin: 4px 0 0; color: var(--fg-muted); font-size: 12.5px; }
.ml-2 { margin-left: 8px; }
/* tab bar (Files / Platform pane) */
.tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; }
.tab {
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 6px 12px;
font-size: 13px;
color: var(--fg-muted);
cursor: pointer;
font-weight: 500;
}
.tab:hover { background: var(--bg-hover); color: var(--fg); }
.tab.active {
background: var(--bg-active);
color: var(--fg);
border-color: var(--border);
}
/* tables (used by Files pane) */
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th, .table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); }
.table th { font-weight: 600; color: var(--fg-muted); background: var(--bg-active); }
.table tbody tr:hover { background: var(--bg-hover); }
/* ---------- meters / gauges ---------- */
.meter {
position: relative;
height: 8px;
background: var(--bg-active);
border-radius: 999px;
overflow: hidden;
}
.meter > span {
display: block;
height: 100%;
background: var(--brand-grad);
border-radius: 999px;
transition: width var(--t) ease;
}
.meter.ok > span { background: linear-gradient(90deg, #16a34a, #4ade80); }
.meter.warn > span { background: linear-gradient(90deg, #d97706, #fbbf24); }
.meter.err > span { background: linear-gradient(90deg, #be123c, #f43f5e); }
.gauge {
display: grid;
grid-template-columns: 110px 1fr 70px;
align-items: center;
gap: 12px;
padding: 8px 0;
font-size: 12.5px;
}
.gauge .gname { color: var(--fg-muted); font-weight: 500; }
.gauge .gval { text-align: right; font-family: var(--font-mono); color: var(--fg); }
/* ---------- segmented control ---------- */
.seg {
display: inline-flex;
background: var(--bg-active);
padding: 3px;
border-radius: var(--r-sm);
gap: 2px;
}
.seg > button {
border: 0;
background: transparent;
color: var(--fg-muted);
padding: 4px 10px;
border-radius: 5px;
font: inherit;
font-size: 12.5px;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.seg > button:hover { color: var(--fg); }
.seg > button.active {
background: var(--bg-elev);
color: var(--fg);
box-shadow: var(--shadow-1);
}
/* ---------- kbd ---------- */
kbd {
display: inline-block;
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-active);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 1px 0 var(--border-2);
}
/* ---------- code / copy ---------- */
pre.code {
position: relative;
background: var(--bg-elev-2);
border: 1px solid var(--border);
font-size: 12px;
line-height: 1.55;
}
.copy-btn {
position: absolute;
top: 6px; right: 6px;
width: 26px; height: 26px;
display: grid; place-items: center;
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg-muted);
border-radius: var(--r-sm);
cursor: pointer;
opacity: 0;
transition: opacity var(--t-fast);
}
.code-wrap { position: relative; }
.code-wrap:hover .copy-btn { opacity: 1; }
.copy-btn:hover { background: var(--bg-hover); color: var(--fg); }
.copy-btn svg { width: 13px; height: 13px; }
/* ---------- sparkline / chart ---------- */
.spark {
width: 100%;
height: 36px;
display: block;
}
.spark path.l { fill: none; stroke: var(--brand); stroke-width: 1.5; }
.spark path.a { fill: rgb(124 92 255 / .14); stroke: none; }
/* ---------- chip / select-multi ---------- */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
background: var(--bg-active);
color: var(--fg);
font-size: 12px;
cursor: pointer;
border: 1px solid transparent;
user-select: none;
transition: background var(--t-fast), border-color var(--t-fast);
}
.chip:hover { background: var(--bg-hover); }
.chip.active { background: rgb(124 92 255 / .15); color: var(--brand); border-color: var(--brand); }
.chip .x { color: var(--fg-faint); }
/* ---------- range slider ---------- */
input[type="range"].slider {
width: 100%;
-webkit-appearance: none;
appearance: none;
background: transparent;
height: 24px;
}
input[type="range"].slider::-webkit-slider-runnable-track {
height: 4px; background: var(--bg-active); border-radius: 999px;
}
input[type="range"].slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--brand);
margin-top: -6px;
box-shadow: 0 1px 2px rgb(0 0 0 / .25);
cursor: pointer;
}
input[type="range"].slider::-moz-range-track {
height: 4px; background: var(--bg-active); border-radius: 999px;
}
input[type="range"].slider::-moz-range-thumb {
width: 16px; height: 16px; border: 0; border-radius: 50%;
background: var(--brand); cursor: pointer;
}
/* ---------- definition list ---------- */
.dl { display: grid; grid-template-columns: 140px 1fr; gap: 6px 12px; font-size: 13px; }
.dl dt { color: var(--fg-muted); font-weight: 500; }
.dl dd { margin: 0; font-family: var(--font-mono); font-size: 12.5px; word-break: break-word; }
/* ---------- small bits ---------- */
.small { font-size: 12px; }
.xsmall { font-size: 11px; }
.text-right { text-align: right; }
.flex-col { display: flex; flex-direction: column; }
.gap-1 { gap: 4px; }
.mt-1 { margin-top: 4px; }
.mb-1 { margin-bottom: 4px; }
.p-0 { padding: 0; }
.p-2 { padding: 8px; }
.no-pad { padding: 0; }
.full-w { width: 100%; }
.scroll-x { overflow-x: auto; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dim { opacity: .65; }
.divider-vert { width: 1px; background: var(--border); margin: 0 8px; align-self: stretch; }
/* ---------- link cards ---------- */
.link-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--r);
text-decoration: none;
color: var(--fg);
background: var(--bg);
transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast);
}
.link-card:hover {
text-decoration: none;
border-color: var(--brand);
background: var(--bg-hover);
transform: translateY(-1px);
}
.link-card .lc-ic {
width: 32px; height: 32px;
display: grid; place-items: center;
border-radius: 8px;
background: rgb(124 92 255 / .14);
color: var(--brand);
flex-shrink: 0;
}
.link-card .lc-ic svg { width: 16px; height: 16px; }
.link-card .lc-title { font-weight: 600; font-size: 13.5px; }
.link-card .lc-sub { font-size: 12px; color: var(--fg-muted); }
/* ---------- collapsible / details ---------- */
details.card-d {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 0;
}
details.card-d > summary {
padding: 10px 14px;
cursor: pointer;
font-weight: 500;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
}
details.card-d > summary::-webkit-details-marker { display: none; }
details.card-d > summary::before {
content: '▸'; color: var(--fg-faint); transition: transform var(--t-fast);
}
details.card-d[open] > summary::before { transform: rotate(90deg); }
details.card-d > .body { padding: 12px 14px; border-top: 1px solid var(--border); }

View File

@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CxWebApp</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1>CxWebApp</h1>
<p class="subtitle">C++ Web Application</p>
</header>
<main>
<section class="card">
<h2>Items</h2>
<div id="items-list">Loading…</div>
<form id="add-form">
<input type="text" id="item-name" placeholder="Name" required>
<input type="text" id="item-desc" placeholder="Description">
<button type="submit">Add Item</button>
</form>
</section>
<section class="card">
<h2>Health</h2>
<pre id="health">Checking…</pre>
</section>
</main>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@ -75,6 +75,10 @@
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><line x1="8" y1="22" x2="16" y2="22"/><line x1="12" y1="18" x2="12" y2="22"/></svg> <svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><line x1="8" y1="22" x2="16" y2="22"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
<span>macOS</span> <span>macOS</span>
</button> </button>
<button class="nav-item" data-tab="files">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>
<span>Files · Platform</span>
</button>
</div> </div>
<div class="nav-group"> <div class="nav-group">
@ -132,6 +136,7 @@
<section class="pane" data-pane="lang"></section> <section class="pane" data-pane="lang"></section>
<section class="pane" data-pane="slack"></section> <section class="pane" data-pane="slack"></section>
<section class="pane" data-pane="mac"></section> <section class="pane" data-pane="mac"></section>
<section class="pane" data-pane="files"></section>
<section class="pane" data-pane="api"></section> <section class="pane" data-pane="api"></section>
<section class="pane" data-pane="websocket"></section> <section class="pane" data-pane="websocket"></section>
<section class="pane" data-pane="system"></section> <section class="pane" data-pane="system"></section>

View File

@ -1,69 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
loadItems();
loadHealth();
document.getElementById("add-form").addEventListener("submit", async (e) => {
e.preventDefault();
const name = document.getElementById("item-name").value.trim();
const desc = document.getElementById("item-desc").value.trim();
if (!name) return;
await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, description: desc }),
});
document.getElementById("item-name").value = "";
document.getElementById("item-desc").value = "";
loadItems();
});
});
async function loadItems() {
const el = document.getElementById("items-list");
try {
const res = await fetch("/api/items");
const data = await res.json();
if (!data.items || data.items.length === 0) {
el.innerHTML = "<p style='color:var(--muted)'>No items yet.</p>";
return;
}
el.innerHTML = data.items
.map(
(item) => `
<div class="item-row">
<div class="item-info">
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.description)}</span>
</div>
<button class="delete-btn" onclick="deleteItem(${item.id})">Delete</button>
</div>`
)
.join("");
} catch {
el.innerHTML = "<p style='color:var(--danger)'>Failed to load items.</p>";
}
}
async function deleteItem(id) {
await fetch(`/api/items/${id}`, { method: "DELETE" });
loadItems();
}
async function loadHealth() {
const el = document.getElementById("health");
try {
const res = await fetch("/api/health");
const data = await res.json();
el.textContent = JSON.stringify(data, null, 2);
} catch {
el.textContent = "Health check failed";
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}

View File

@ -31,6 +31,7 @@ const PANE_LOADERS = {
lang: () => import('./panes/lang.js'), lang: () => import('./panes/lang.js'),
slack: () => import('./panes/slack.js'), slack: () => import('./panes/slack.js'),
mac: () => import('./panes/mac.js'), mac: () => import('./panes/mac.js'),
files: () => import('./panes/files.js'),
api: () => import('./panes/api.js'), api: () => import('./panes/api.js'),
websocket: () => import('./panes/websocket.js'), websocket: () => import('./panes/websocket.js'),
system: () => import('./panes/system.js'), system: () => import('./panes/system.js'),
@ -40,7 +41,7 @@ const PANE_LOADERS = {
const NAV_LABELS = { const NAV_LABELS = {
dashboard: 'Dashboard', agent: 'Agent', inbox: 'Inbox', tools: 'Tools', dashboard: 'Dashboard', agent: 'Agent', inbox: 'Inbox', tools: 'Tools',
items: 'Items', diffusion: 'Diffusion', demand: 'Demand', lang: 'Lang', items: 'Items', diffusion: 'Diffusion', demand: 'Demand', lang: 'Lang',
slack: 'Slack', mac: 'macOS', api: 'API Explorer', websocket: 'WebSocket', slack: 'Slack', mac: 'macOS', files: 'Files · Platform', api: 'API Explorer', websocket: 'WebSocket',
system: 'System', about: 'About', system: 'System', about: 'About',
}; };

View File

@ -150,3 +150,136 @@ export function setFooterBuild(text) {
const el = document.getElementById('footer-build'); const el = document.getElementById('footer-build');
if (el) el.textContent = text || 'build —'; if (el) el.textContent = text || 'build —';
} }
// ---------- additional reusable helpers ----------
export async function copyToClipboard(text, label = 'copied') {
try {
await navigator.clipboard.writeText(text);
ok(label);
} catch (_) {
// fallback for non-secure contexts
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); ok(label); } catch { err('copy failed'); }
ta.remove();
}
}
// Wraps any `<pre>` so it gets a copy button on hover.
export function withCopy(html, id = '') {
const safeId = id || `c${Math.random().toString(36).slice(2, 7)}`;
return `<div class="code-wrap">
<pre class="code" id="${safeId}">${html}</pre>
<button class="copy-btn" data-copy-target="${safeId}" title="Copy to clipboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
</button>
</div>`;
}
// Wire up any .copy-btn[data-copy-target] inside `host` after rendering.
export function bindCopyButtons(host) {
host.querySelectorAll('.copy-btn[data-copy-target]').forEach(btn => {
if (btn.dataset.bound) return;
btn.dataset.bound = '1';
btn.addEventListener('click', () => {
const t = host.querySelector('#' + btn.dataset.copyTarget);
if (t) copyToClipboard(t.textContent || '');
});
});
}
// Renders a tiny sparkline (svg) for an array of numbers (0..N values).
export function sparkline(values, { w = 220, h = 36 } = {}) {
if (!values || values.length === 0) return `<svg class="spark" viewBox="0 0 ${w} ${h}"></svg>`;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const dx = w / Math.max(1, values.length - 1);
const pts = values.map((v, i) => `${(i * dx).toFixed(1)},${(h - ((v - min) / span) * (h - 4) - 2).toFixed(1)}`);
const line = `M ${pts.join(' L ')}`;
const area = `${line} L ${w},${h} L 0,${h} Z`;
return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<path class="a" d="${area}" />
<path class="l" d="${line}" />
</svg>`;
}
// Render a horizontal meter row (0..100). State: ok/warn/err/none.
export function meterRow(label, pct, value, state = '') {
const safe = Math.max(0, Math.min(100, Number(pct) || 0));
return `<div class="gauge">
<span class="gname">${escapeHtml(label)}</span>
<div class="meter ${escapeHtml(state)}"><span style="width:${safe}%"></span></div>
<span class="gval">${escapeHtml(value)}</span>
</div>`;
}
// Pretty-print numbers (bytes / counts).
export function fmtBytes(n) {
if (!n && n !== 0) return '—';
const u = ['B','KB','MB','GB','TB'];
let i = 0, v = +n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : v >= 10 ? 1 : 2)} ${u[i]}`;
}
export function fmtNum(n) {
if (n == null || Number.isNaN(+n)) return '—';
return (+n).toLocaleString();
}
// Tiny localStorage-backed history of items (most recent first).
export function historyStore(key, max = 25) {
const read = () => {
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch { return []; }
};
return {
list: read,
push(item) {
const arr = read();
arr.unshift({ ...item, _ts: Date.now() });
while (arr.length > max) arr.pop();
try { localStorage.setItem(key, JSON.stringify(arr)); } catch {}
},
clear() { try { localStorage.removeItem(key); } catch {} },
};
}
// Generate a quick HTML form input set from a JSON Schema (best-effort, flat).
export function inputFromSchema(name, schema) {
const lbl = name + (schema?.description ? `${schema.description}` : '');
if (!schema) return `<label class="field"><span class="lbl">${escapeHtml(name)}</span><input class="input" data-k="${escapeHtml(name)}"></label>`;
if (schema.enum?.length) {
const opts = schema.enum.map(v => `<option value="${escapeHtml(String(v))}">${escapeHtml(String(v))}</option>`).join('');
return `<label class="field"><span class="lbl">${escapeHtml(lbl)}</span><select class="input" data-k="${escapeHtml(name)}">${opts}</select></label>`;
}
if (schema.type === 'boolean') {
return `<label class="check" style="margin-top:8px"><input type="checkbox" data-k="${escapeHtml(name)}" data-kind="bool" ${schema.default ? 'checked' : ''}/> ${escapeHtml(lbl)}</label>`;
}
if (schema.type === 'integer' || schema.type === 'number') {
return `<label class="field"><span class="lbl">${escapeHtml(lbl)}</span>
<input class="input" type="number" data-k="${escapeHtml(name)}" data-kind="num"${schema.default != null ? ` value="${schema.default}"` : ''}/></label>`;
}
if (schema.type === 'object' || schema.type === 'array') {
return `<label class="field"><span class="lbl">${escapeHtml(lbl)} <span class="muted xsmall">(JSON)</span></span>
<textarea class="input" rows="3" data-k="${escapeHtml(name)}" data-kind="json">${schema.default != null ? escapeHtml(JSON.stringify(schema.default)) : ''}</textarea></label>`;
}
return `<label class="field"><span class="lbl">${escapeHtml(lbl)}</span>
<input class="input" data-k="${escapeHtml(name)}"${schema.default != null ? ` value="${escapeHtml(String(schema.default))}"` : ''}/></label>`;
}
export function collectSchemaForm(container) {
const out = {};
container.querySelectorAll('[data-k]').forEach(el => {
const k = el.dataset.k;
const kind = el.dataset.kind || (el.type === 'number' ? 'num' : '');
let v;
if (el.type === 'checkbox') v = el.checked;
else v = el.value;
if (v === '' || v == null) return;
if (kind === 'num') v = Number(v);
else if (kind === 'json') { try { v = JSON.parse(v); } catch { /* leave as string */ } }
out[k] = v;
});
return out;
}

View File

@ -1,35 +1,166 @@
// panes/about.js — static metadata + links. // panes/about.js — runtime metadata, shortcut grid, public links, credits.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { MCP_BASE } from '../lib/api.js'; import { MCP_BASE, jget, escapeHtml } from '../lib/api.js';
import { fmtJSON, copyToClipboard, ok } from '../lib/ui.js';
const SHORTCUTS = [
['⌘K / Ctrl-K', 'Open command palette'],
['Esc', 'Close palette / modal'],
['↑ ↓ Enter', 'Navigate palette results'],
['1 … 9', 'Quick-jump to a sidebar tab (when palette open)'],
];
const PLATFORM = [
{ tab: 'dashboard', label: 'Dashboard', sub: 'KPIs, service health, activity' },
{ tab: 'agent', label: 'Agent', sub: 'MCP status + live events + search' },
{ tab: 'inbox', label: 'Inbox', sub: 'Sweep & route artifacts' },
{ tab: 'tools', label: 'Tools', sub: 'Browse and invoke MCP tools' },
{ tab: 'items', label: 'Items', sub: 'CRUD against /api/items' },
];
const SERVICES = [
{ tab: 'diffusion', label: 'Diffusion', sub: 'Stable Diffusion sidecar' },
{ tab: 'demand', label: 'Demand', sub: 'Trend-driven design jobs' },
{ tab: 'lang', label: 'Lang', sub: 'Language pipelines' },
{ tab: 'slack', label: 'Slack', sub: 'Slack sidecar' },
{ tab: 'mac', label: 'macOS', sub: 'Native app distribution' },
{ tab: 'files', label: 'Files', sub: 'Platform Studio mirror' },
];
const DEVELOP = [
{ tab: 'api', label: 'API Explorer', sub: 'Hit any backend route' },
{ tab: 'websocket', label: 'WebSocket', sub: 'Live frame console' },
{ tab: 'system', label: 'System', sub: 'Runtime & host facts' },
];
const PUBLIC_LINKS = [
{ url: 'https://webapp.cxllm.io/', label: 'webapp.cxllm.io', sub: 'production' },
{ url: 'https://auth.cxllm.io/if/user/#/library', label: 'App library', sub: 'all CxAI tiles' },
{ url: 'https://api.cxllm.io/', label: 'api.cxllm.io', sub: 'public API gateway' },
{ url: 'https://mcp.cxllm.io/', label: 'mcp.cxllm.io', sub: 'MCP control plane' },
{ url: 'https://files.cxllm.io/', label: 'files.cxllm.io', sub: 'Files Platform Studio' },
{ url: 'https://code.cxllm.io/', label: 'code.cxllm.io', sub: 'code-server' },
{ url: 'https://monitor.cxllm.io/', label: 'monitor.cxllm.io', sub: 'Grafana' },
{ url: 'https://registry.cxllm.io/v2/_catalog', label: 'Container registry', sub: 'cxai/* images' },
];
function linkCardInternal(t) {
return `<a class="link-card" href="#${t.tab}">
<div class="lc-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
<div class="grow"><div class="lc-title">${escapeHtml(t.label)}</div><div class="lc-sub">${escapeHtml(t.sub)}</div></div>
</a>`;
}
function linkCardExternal(t) {
return `<a class="link-card" href="${escapeHtml(t.url)}" target="_blank" rel="noopener">
<div class="lc-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3h7v7"/><path d="M10 14L21 3"/><path d="M5 5v14a2 2 0 0 0 2 2h14"/></svg></div>
<div class="grow"><div class="lc-title">${escapeHtml(t.label)}</div><div class="lc-sub">${escapeHtml(t.sub)}</div></div>
</a>`;
}
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">About</div><div class="sub">CxWebApp control center.</div></div> <div><div class="title">About</div><div class="sub">CxWebApp control center single-page UI over the Crow backend and all sidecars.</div></div>
<div class="grow"></div>
<button class="btn btn-secondary" id="ab-share">Share build info</button>
</div> </div>
<div class="grid cols-3 mb-3">
<div class="card">
<div class="card-title"><h2>Build</h2></div>
<dl class="dl" id="ab-build"></dl>
</div>
<div class="card">
<div class="card-title"><h2>Host</h2></div>
<dl class="dl" id="ab-host"></dl>
</div>
<div class="card">
<div class="card-title"><h2>Connectivity</h2></div>
<dl class="dl">
<dt>MCP base</dt><dd>${escapeHtml(MCP_BASE)}</dd>
<dt>Origin</dt><dd>${escapeHtml(location.origin)}</dd>
<dt>Theme</dt><dd id="ab-theme"></dd>
<dt>User agent</dt><dd class="truncate" title="${escapeHtml(navigator.userAgent)}">${escapeHtml(navigator.userAgent)}</dd>
</dl>
</div>
</div>
<div class="grid cols-2 mb-3">
<div class="card">
<div class="card-title"><h2>Platform panes</h2></div>
<div class="grid cols-2 gap-2">${PLATFORM.map(linkCardInternal).join('')}</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Services</h2></div>
<div class="grid cols-2 gap-2">${SERVICES.map(linkCardInternal).join('')}</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Develop</h2></div>
<div class="grid cols-2 gap-2">${DEVELOP.map(linkCardInternal).join('')}</div>
</div>
<div class="card">
<div class="card-title"><h2>Public links</h2></div>
<div class="grid cols-2 gap-2">${PUBLIC_LINKS.map(linkCardExternal).join('')}</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Keyboard shortcuts</h2></div>
<table class="table">
<tbody>${SHORTCUTS.map(([k, d]) => `<tr><td style="width:140px"><kbd>${escapeHtml(k)}</kbd></td><td class="muted small">${escapeHtml(d)}</td></tr>`).join('')}</tbody>
</table>
</div>
</div>
<div class="grid cols-2"> <div class="grid cols-2">
<div class="card"> <div class="card">
<h2>What is this?</h2> <div class="card-title"><h2>Architecture</h2></div>
<p class="muted mt-2">Single-page UI for the CxWebApp Crow backend. Sidecars (diffusion / demand / lang / slack) and the CxAI MCP agent are all exposed through this one console.</p> <p class="muted small">Crow (HTTP/WebSocket) + cpp-httplib (reverse proxy) compiled into a single C++ binary. Static UI is plain ES modules (no build step). All sidecars are reached through <code>/api/&lt;service&gt;/*</code> reverse-proxy routes registered from <code>src/routes/proxy.cpp</code>.</p>
<h3 class="mt-4 mb-2">Endpoints</h3> <ul class="muted small" style="line-height:1.9">
<ul class="muted" style="line-height:1.9"> <li><code>src/routes/api.cpp</code> items CRUD</li>
<li><code>/api/health</code>, <code>/api/version</code>, <code>/api/system</code></li> <li><code>src/routes/system.cpp</code> /api/version, /api/system, /ws/echo</li>
<li><code>/api/items</code> · <code>/api/diffusion/*</code> · <code>/api/demand/*</code></li> <li><code>src/routes/proxy.cpp</code> — diffusion / demand / lang / slack / files</li>
<li><code>/api/lang/*</code> · <code>/api/slack/*</code> · <code>/api/mac/*</code></li> <li><code>src/routes/mac.cpp</code> /api/mac/* native app distribution</li>
<li><code>/ws/echo</code></li> <li><code>src/routes/pages.cpp</code> static SPA serving</li>
<li>MCP base: <code>${MCP_BASE}</code></li>
</ul> </ul>
</div> </div>
<div class="card"> <div class="card">
<h2>Shortcuts</h2> <div class="card-title"><h2>Configuration env-vars</h2></div>
<p class="muted mt-2">Use <kbd>K</kbd> / <kbd>Ctrl-K</kbd> to open the command palette.</p> <table class="table">
<p class="muted mt-2">Theme is persisted in <code>localStorage</code> as <code>cx_theme</code>.</p> <thead><tr><th>variable</th><th>purpose</th></tr></thead>
<h3 class="mt-4 mb-2">Configuring the MCP base</h3> <tbody>
<p class="muted">Set <code>data-mcp-base="…"</code> on <code>&lt;body&gt;</code> in <code>static/index.html</code>.</p> <tr><td><code>CXWEBAPP_VERSION</code></td><td>Version string returned by /api/version.</td></tr>
<tr><td><code>CXWEBAPP_GIT_SHA</code></td><td>Build SHA.</td></tr>
<tr><td><code>CXWEBAPP_BUILD_TIME</code></td><td>RFC3339 build timestamp.</td></tr>
<tr><td><code>CXWEBAPP_STATIC_DIR</code></td><td>Override static asset directory.</td></tr>
<tr><td><code>CXAI_DIFFUSION_UPSTREAM</code></td><td>Diffusion sidecar URL.</td></tr>
<tr><td><code>CXAI_DEMAND_UPSTREAM</code></td><td>Demand sidecar URL.</td></tr>
<tr><td><code>CXAI_LANG_UPSTREAM</code></td><td>Lang sidecar URL.</td></tr>
<tr><td><code>CXAI_SLACK_UPSTREAM</code></td><td>Slack sidecar URL.</td></tr>
<tr><td><code>CXAI_FILES_UPSTREAM</code></td><td>Files PostgREST URL.</td></tr>
<tr><td><code>FILES_ANON_KEY</code></td><td>PostgREST anon key (injected by proxy).</td></tr>
</tbody>
</table>
</div> </div>
</div> </div>
`; `;
function row(k, v) { return `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v ?? '—')}</dd>`; }
registerPane('about', { registerPane('about', {
label: 'About', label: 'About',
init(host) { host.innerHTML = TPL; }, async init(host) {
host.innerHTML = TPL;
host.querySelector('#ab-theme').textContent = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
try {
const [v, s] = await Promise.all([jget('/api/version'), jget('/api/system')]);
host.querySelector('#ab-build').innerHTML =
row('Name', v.name) + row('Version', v.version) + row('Git SHA', (v.git_sha || '').slice(0, 12)) + row('Build time', v.build_time);
host.querySelector('#ab-host').innerHTML =
row('Hostname', s.hostname) + row('OS', `${s.sysname || ''} ${s.release || ''}`.trim()) +
row('Arch', s.machine) + row('PID', s.pid) + row('CPUs', s.cpu_count);
} catch (e) {
host.querySelector('#ab-build').innerHTML = `<dt>error</dt><dd>${escapeHtml(e.message)}</dd>`;
}
host.querySelector('#ab-share').addEventListener('click', async () => {
try {
const [v, s] = await Promise.all([jget('/api/version'), jget('/api/system')]);
copyToClipboard(fmtJSON({ ...v, host: s.hostname, sys: `${s.sysname} ${s.release}` }), 'build info copied');
} catch (e) { copyToClipboard(location.href, 'url copied'); }
});
},
}); });

View File

@ -1,8 +1,10 @@
// panes/agent.js — absorbs the standalone CxAI dashboard. // panes/agent.js — MCP control plane + live events + semantic search with filters.
// MCP status + live events feed (2s poll) + semantic search.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { mcp, MCP_BASE, escapeHtml } from '../lib/api.js'; import { mcp, MCP_BASE, escapeHtml } from '../lib/api.js';
import { ok, err, fmtJSON, skeleton } from '../lib/ui.js'; import { ok, err, fmtJSON, skeleton, copyToClipboard, historyStore } from '../lib/ui.js';
const searchHist = historyStore('cx_agent_search', 20);
const kindFilter = new Set();
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
@ -14,30 +16,44 @@ const TPL = `
<span class="pill" id="agent-pill"><span class="dot"></span><span id="agent-pill-text">checking</span></span> <span class="pill" id="agent-pill"><span class="dot"></span><span id="agent-pill-text">checking</span></span>
</div> </div>
<div class="grid cols-2"> <div class="grid cols-3 mb-3" id="agent-kpis"></div>
<div class="grid cols-2 mb-3">
<div class="card"> <div class="card">
<div class="card-title"><h2>Status</h2><span class="muted mono" id="agent-status-ts"></span></div> <div class="card-title"><h2>Status</h2>
<pre id="agent-status">${skeleton(4)}</pre> <div class="flex gap-2 items-center">
<span class="muted mono xsmall" id="agent-status-ts"></span>
<button class="btn btn-ghost" id="agent-copy-status">Copy</button>
</div>
</div>
<pre id="agent-status" class="code" style="max-height:28vh">${skeleton(4)}</pre>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Semantic search</h2><span class="muted" id="agent-search-meta"></span></div> <div class="card-title"><h2>Semantic search</h2><span class="muted xsmall" id="agent-search-meta"></span></div>
<form id="agent-search-form" class="flex gap-2 mb-3"> <form id="agent-search-form" class="flex gap-2 mb-2">
<input class="input" id="agent-q" placeholder="ask the index…" autocomplete="off" /> <input class="input" id="agent-q" placeholder="ask the index…" autocomplete="off"/>
<input class="input" id="agent-k" type="number" min="1" max="50" value="10" style="max-width:80px"/>
<button class="btn btn-primary" type="submit">Search</button> <button class="btn btn-primary" type="submit">Search</button>
</form> </form>
<div id="agent-hits" class="muted">Run a query to see results.</div> <div class="chips mb-2" id="agent-recent"></div>
<div id="agent-hits" class="scroll muted" style="max-height:34vh">Run a query to see results.</div>
</div> </div>
</div> </div>
<div class="card mt-3"> <div class="card">
<div class="card-title"> <div class="card-title">
<h2>Live events</h2> <h2>Live events</h2>
<label class="check"><input type="checkbox" id="agent-tail" checked /> auto-refresh (2s)</label> <div class="flex gap-2 items-center">
<label class="check"><input type="checkbox" id="agent-tail" checked/> auto (2s)</label>
<input class="input" id="agent-evfilter" placeholder="filter detail…" style="max-width:200px"/>
<button class="btn btn-ghost" id="agent-clear-kinds">Clear filters</button>
</div>
</div> </div>
<div class="scroll" style="max-height: 50vh"> <div class="chips mb-2" id="agent-kinds"></div>
<div class="scroll" style="max-height: 46vh">
<table class="t" id="agent-events"> <table class="t" id="agent-events">
<thead><tr><th style="width:160px">time</th><th style="width:200px">kind</th><th>detail</th></tr></thead> <thead><tr><th style="width:140px">time</th><th style="width:180px">kind</th><th>detail</th></tr></thead>
<tbody><tr><td colspan="3" class="muted">loading</td></tr></tbody> <tbody><tr><td colspan="3" class="muted">loading</td></tr></tbody>
</table> </table>
</div> </div>
@ -45,6 +61,8 @@ const TPL = `
`; `;
let timer = null; let timer = null;
let lastEvents = [];
let lastStatus = null;
function setPill(state, text) { function setPill(state, text) {
const p = document.getElementById('agent-pill'); const p = document.getElementById('agent-pill');
@ -60,12 +78,22 @@ function setPill(state, text) {
} }
} }
function kpi(label, value, sub) {
return `<div class="kpi"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div><div class="sub">${escapeHtml(sub || '')}</div></div>`;
}
async function refreshStatus(host) { async function refreshStatus(host) {
try { try {
const s = await mcp.status(); const s = await mcp.status();
lastStatus = s;
host.querySelector('#agent-status').textContent = fmtJSON(s); host.querySelector('#agent-status').textContent = fmtJSON(s);
host.querySelector('#agent-status-ts').textContent = new Date().toLocaleTimeString(); host.querySelector('#agent-status-ts').textContent = new Date().toLocaleTimeString();
setPill('ok', 'connected'); setPill('ok', 'connected');
host.querySelector('#agent-kpis').innerHTML = [
kpi('Tools', String(s.tools_count ?? s.tools?.length ?? '—'), 'registered'),
kpi('Events', String(s.events_count ?? s.events ?? lastEvents.length), 'since boot'),
kpi('Index', String(s.index_size ?? s.embeddings ?? '—'), 'vectors'),
].join('');
} catch (e) { } catch (e) {
host.querySelector('#agent-status').textContent = `error: ${e.message}\n\nIs the MCP HTTP sidecar running on ${MCP_BASE}?`; host.querySelector('#agent-status').textContent = `error: ${e.message}\n\nIs the MCP HTTP sidecar running on ${MCP_BASE}?`;
setPill('err', 'unavailable'); setPill('err', 'unavailable');
@ -74,48 +102,83 @@ async function refreshStatus(host) {
function fmtEventTs(ts) { function fmtEventTs(ts) {
if (!ts) return ''; if (!ts) return '';
try { try { if (typeof ts === 'number') return new Date(ts * (ts < 1e12 ? 1000 : 1)).toLocaleTimeString(); return new Date(ts).toLocaleTimeString(); }
if (typeof ts === 'number') return new Date(ts * (ts < 1e12 ? 1000 : 1)).toLocaleTimeString(); catch { return String(ts); }
return new Date(ts).toLocaleTimeString(); }
} catch { return String(ts); }
function renderKinds(host) {
const counts = {};
lastEvents.forEach(e => { const k = e.kind || e.type || 'event'; counts[k] = (counts[k] || 0) + 1; });
const ks = Object.entries(counts).sort((a, b) => b[1] - a[1]);
host.querySelector('#agent-kinds').innerHTML = ks.map(([k, n]) =>
`<button class="chip ${kindFilter.has(k) ? 'active' : ''}" data-k="${escapeHtml(k)}">${escapeHtml(k)} <span class="muted">${n}</span></button>`
).join('');
host.querySelectorAll('#agent-kinds .chip').forEach(b => b.addEventListener('click', () => {
const k = b.dataset.k; if (kindFilter.has(k)) kindFilter.delete(k); else kindFilter.add(k);
renderKinds(host); renderEvents(host);
}));
}
function renderEvents(host) {
const q = host.querySelector('#agent-evfilter')?.value.trim().toLowerCase() || '';
const filtered = lastEvents
.filter(ev => !kindFilter.size || kindFilter.has(ev.kind || ev.type || 'event'))
.filter(ev => {
if (!q) return true;
const blob = (typeof ev.detail === 'string' ? ev.detail : JSON.stringify(ev.detail ?? ev.data ?? ev)).toLowerCase();
return blob.includes(q);
});
const rows = filtered.slice().reverse().slice(0, 200).map(ev => `<tr>
<td class="mono muted">${escapeHtml(fmtEventTs(ev.ts ?? ev.timestamp ?? ev.time))}</td>
<td><span class="pill info">${escapeHtml(ev.kind || ev.type || 'event')}</span></td>
<td class="mono" style="word-break:break-word">${escapeHtml(typeof ev.detail === 'string' ? ev.detail : JSON.stringify(ev.detail ?? ev.data ?? ev))}</td>
</tr>`).join('');
host.querySelector('#agent-events tbody').innerHTML = rows || '<tr><td colspan="3" class="muted">no events match</td></tr>';
} }
async function refreshEvents(host) { async function refreshEvents(host) {
try { try {
const arr = await mcp.events(150); lastEvents = (await mcp.events(200)) || [];
const rows = (arr || []).slice().reverse().slice(0, 100).map(ev => ` renderKinds(host);
<tr> renderEvents(host);
<td class="mono muted">${escapeHtml(fmtEventTs(ev.ts ?? ev.timestamp ?? ev.time))}</td>
<td><span class="pill info">${escapeHtml(ev.kind || ev.type || 'event')}</span></td>
<td class="mono" style="word-break:break-word">${escapeHtml(typeof ev.detail === 'string' ? ev.detail : JSON.stringify(ev.detail ?? ev.data ?? ev))}</td>
</tr>
`).join('');
host.querySelector('#agent-events tbody').innerHTML =
rows || '<tr><td colspan="3" class="muted">no events yet</td></tr>';
} catch (e) { } catch (e) {
host.querySelector('#agent-events tbody').innerHTML = host.querySelector('#agent-events tbody').innerHTML =
`<tr><td colspan="3" class="muted">events unavailable: ${escapeHtml(e.message)}</td></tr>`; `<tr><td colspan="3" class="muted">events unavailable: ${escapeHtml(e.message)}</td></tr>`;
} }
} }
function renderRecent(host) {
const list = searchHist.list();
host.querySelector('#agent-recent').innerHTML = list.length
? list.slice(0, 8).map(h => `<button class="chip" data-q="${escapeHtml(h.q)}">${escapeHtml(h.q)}</button>`).join('')
: '';
host.querySelectorAll('#agent-recent .chip').forEach(b => b.addEventListener('click', () => {
host.querySelector('#agent-q').value = b.dataset.q;
host.querySelector('#agent-search-form').requestSubmit();
}));
}
async function search(host) { async function search(host) {
const q = host.querySelector('#agent-q').value.trim(); const q = host.querySelector('#agent-q').value.trim();
if (!q) return; if (!q) return;
const k = +host.querySelector('#agent-k').value || 10;
host.querySelector('#agent-search-meta').textContent = 'searching…'; host.querySelector('#agent-search-meta').textContent = 'searching…';
host.querySelector('#agent-hits').innerHTML = skeleton(3); host.querySelector('#agent-hits').innerHTML = skeleton(3);
try { try {
const res = await mcp.call('search_semantic_tool', { query: q, k: 10 }); const res = await mcp.call('search_semantic_tool', { query: q, k });
const hits = res?.hits || res?.result?.hits || res?.results || res || []; const hits = res?.hits || res?.result?.hits || res?.results || res || [];
host.querySelector('#agent-search-meta').textContent = `${hits.length} hits`; host.querySelector('#agent-search-meta').textContent = `${hits.length} hits`;
host.querySelector('#agent-hits').innerHTML = hits.length ? hits.map(h => ` host.querySelector('#agent-hits').innerHTML = hits.length
<div class="row"> ? hits.map(h => `<div class="row">
<div class="grow"> <div class="grow">
<div class="ttl mono">${escapeHtml(h.path || h.id || '?')}</div> <div class="ttl mono">${escapeHtml(h.path || h.id || '?')}</div>
<div class="desc">${escapeHtml((h.snippet || h.text || '').slice(0, 280))}</div> <div class="desc">${escapeHtml((h.snippet || h.text || '').slice(0, 280))}</div>
</div> </div>
<span class="pill muted mono">${(h.distance ?? h.score ?? '').toString().slice(0, 6)}</span> <span class="pill muted mono xsmall">${(h.distance ?? h.score ?? '').toString().slice(0, 6)}</span>
</div> </div>`).join('')
`).join('') : '<div class="muted" style="padding:12px">no hits</div>'; : '<div class="muted" style="padding:12px">no hits</div>';
searchHist.push({ q });
renderRecent(host);
} catch (e) { } catch (e) {
host.querySelector('#agent-search-meta').textContent = 'error'; host.querySelector('#agent-search-meta').textContent = 'error';
host.querySelector('#agent-hits').innerHTML = `<div class="muted" style="padding:12px">${escapeHtml(e.message)}</div>`; host.querySelector('#agent-hits').innerHTML = `<div class="muted" style="padding:12px">${escapeHtml(e.message)}</div>`;
@ -127,22 +190,18 @@ registerPane('agent', {
label: 'Agent', label: 'Agent',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#agent-search-form').addEventListener('submit', (e) => { host.querySelector('#agent-search-form').addEventListener('submit', (e) => { e.preventDefault(); search(host); });
e.preventDefault(); search(host); host.querySelector('#agent-evfilter').addEventListener('input', () => renderEvents(host));
}); host.querySelector('#agent-clear-kinds').addEventListener('click', () => { kindFilter.clear(); renderKinds(host); renderEvents(host); });
refreshStatus(host); host.querySelector('#agent-copy-status').addEventListener('click', () => copyToClipboard(fmtJSON(lastStatus || {}), 'status copied'));
refreshEvents(host);
const tick = () => { renderRecent(host);
if (timer) clearInterval(timer); refreshStatus(host); refreshEvents(host);
timer = setInterval(() => { timer = setInterval(() => {
if (!host.classList.contains('active')) return; if (!host.classList.contains('active')) return;
if (!host.querySelector('#agent-tail')?.checked) return; if (!host.querySelector('#agent-tail')?.checked) return;
refreshEvents(host); refreshEvents(host); refreshStatus(host);
refreshStatus(host); }, 2000);
}, 2000);
};
tick();
}, },
refresh(host) { refreshStatus(host); refreshEvents(host); }, refresh(host) { refreshStatus(host); refreshEvents(host); },
}); });

View File

@ -1,49 +1,268 @@
// panes/api.js — interactive request explorer. // panes/api.js — interactive HTTP explorer with presets, headers, history, share.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jraw } from '../lib/api.js'; import { jraw, escapeHtml } from '../lib/api.js';
import { fmtJSON } from '../lib/ui.js'; import { fmtJSON, ok, err, copyToClipboard, historyStore, withCopy, bindCopyButtons } from '../lib/ui.js';
const PRESETS = [
{ label: 'health', method: 'GET', path: '/api/health' },
{ label: 'version', method: 'GET', path: '/api/version' },
{ label: 'system', method: 'GET', path: '/api/system' },
{ label: 'services', method: 'GET', path: '/api/services' },
{ label: 'items', method: 'GET', path: '/api/items' },
{ label: 'create item', method: 'POST', path: '/api/items',
body: '{\n "name": "demo",\n "description": "from API explorer"\n}' },
{ label: 'mac info', method: 'GET', path: '/api/mac/info' },
{ label: 'echo', method: 'POST', path: '/api/echo', body: '{"hello":"world"}' },
{ label: 'diffusion health', method: 'GET', path: '/api/diffusion/healthz' },
{ label: 'demand reports', method: 'GET', path: '/api/demand/reports' },
{ label: 'lang pipelines', method: 'GET', path: '/api/lang/pipelines' },
{ label: 'slack info', method: 'GET', path: '/api/slack/info' },
{ label: 'files providers', method: 'GET', path: '/api/files/mcp_providers?select=*' },
];
const history = historyStore('cx_api_history', 30);
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">API Explorer</div><div class="sub">Hit any backend route.</div></div> <div><div class="title">API Explorer</div><div class="sub">Hit any backend route auto-saves history, supports custom headers and presets.</div></div>
<div class="grow"></div>
<button class="btn btn-ghost" id="ax-clear-hist">Clear history</button>
<button class="btn btn-secondary" id="ax-share">Share link</button>
</div> </div>
<div class="card">
<div class="flex gap-2 mb-3"> <div class="grid" style="grid-template-columns: 240px 1fr; gap: 16px;">
<select class="input" id="ax-method" style="max-width:120px"> <div class="card no-pad" style="padding: 8px;">
<option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option> <div class="card-title" style="padding: 8px 10px;"><h2 style="font-size:13px">Presets</h2></div>
</select> <div id="ax-presets" style="display:flex; flex-direction:column; gap:2px"></div>
<input class="input" id="ax-path" value="/api/health" placeholder="/api/…"/> <div class="divider"></div>
<button class="btn btn-primary" id="ax-send">Send</button> <div class="card-title" style="padding: 4px 10px;"><h2 style="font-size:13px">History</h2><span class="muted xsmall" id="ax-hist-count">0</span></div>
<button class="btn btn-ghost" id="ax-clear">Clear</button> <div id="ax-history" style="display:flex; flex-direction:column; gap:2px; max-height:40vh; overflow:auto"></div>
</div>
<div>
<div class="card mb-3">
<div class="flex gap-2 mb-3">
<select class="input" id="ax-method" style="max-width:120px">
<option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option><option>HEAD</option>
</select>
<input class="input" id="ax-path" value="/api/health" placeholder="/api/…"/>
<button class="btn btn-primary" id="ax-send">Send</button>
</div>
<div class="seg mb-3" id="ax-tabs">
<button class="active" data-tab="body">Body</button>
<button data-tab="headers">Headers</button>
<button data-tab="params">Query</button>
</div>
<div data-pane-tab="body">
<label class="check mb-2"><input type="checkbox" id="ax-pretty" checked/> pretty-print outgoing JSON on Format</label>
<textarea class="input" id="ax-body" rows="8" placeholder='{"key": "value"}'></textarea>
<div class="btn-row mt-2">
<button class="btn btn-ghost" id="ax-fmt">Format JSON</button>
<button class="btn btn-ghost" id="ax-copy-body">Copy</button>
<button class="btn btn-ghost" id="ax-as-curl">Copy as cURL</button>
</div>
</div>
<div data-pane-tab="headers" style="display:none">
<div class="muted xsmall mb-2">One per line, format <code>Header: value</code>. Defaults <code>Content-Type: application/json</code> + <code>Accept: application/json</code> are added automatically.</div>
<textarea class="input" id="ax-headers" rows="6" placeholder="X-Trace-Id: abc-123"></textarea>
</div>
<div data-pane-tab="params" style="display:none">
<div class="muted xsmall mb-2">Auto-appended to the URL. <code>key=value</code>, one per line.</div>
<textarea class="input" id="ax-params" rows="6" placeholder="limit=10"></textarea>
</div>
</div>
<div class="card">
<div class="card-title">
<h2>Response</h2>
<span class="flex gap-2 items-center">
<span class="pill muted" id="ax-status"></span>
<button class="btn btn-ghost" id="ax-copy-resp">Copy</button>
<button class="btn btn-ghost" id="ax-resp-pretty">Toggle pretty</button>
</span>
</div>
<div id="ax-resp-meta" class="muted xsmall mb-2">no request yet</div>
<pre id="ax-out" class="code" style="max-height:55vh">no request yet</pre>
</div>
</div> </div>
<label class="field mb-3"><span class="lbl">Body (JSON or text)</span><textarea class="input" id="ax-body" rows="5"></textarea></label>
<div class="card-title"><h2>Response</h2><span class="muted mono" id="ax-status"></span></div>
<pre id="ax-out" class="muted">no request yet</pre>
</div> </div>
`; `;
let prettyMode = true;
let lastResponse = null;
let lastRequest = null;
function parseKVLines(s, sep) {
return (s || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => {
const i = l.indexOf(sep);
return i < 0 ? null : [l.slice(0, i).trim(), l.slice(i + sep.length).trim()];
}).filter(Boolean);
}
function buildUrl(host, path, paramsText) {
const params = parseKVLines(paramsText, '=');
if (!params.length) return path;
const usp = new URLSearchParams();
for (const [k, v] of params) usp.append(k, v);
return path + (path.includes('?') ? '&' : '?') + usp.toString();
}
function asCurl(req) {
const parts = ['curl', '-X', req.method, JSON.stringify(req.url)];
for (const [k, v] of (req.headers || [])) parts.push('-H', JSON.stringify(`${k}: ${v}`));
if (req.body) parts.push('--data-raw', JSON.stringify(req.body));
return parts.join(' ');
}
function renderResponse(host) {
const out = host.querySelector('#ax-out');
if (!lastResponse) { out.textContent = 'no response yet'; return; }
const { body, raw } = lastResponse;
if (prettyMode) out.textContent = typeof body === 'string' ? body : fmtJSON(body);
else out.textContent = raw;
}
function renderHistory(host) {
const items = history.list();
host.querySelector('#ax-hist-count').textContent = String(items.length);
host.querySelector('#ax-history').innerHTML = items.length
? items.map((h, i) => `<button class="nav-item" data-hi="${i}" title="${escapeHtml(h.path)}">
<span class="pill ${h.status >= 200 && h.status < 300 ? 'ok' : h.status >= 400 ? 'err' : 'warn'}" style="font-size:10px">${h.method}</span>
<span class="truncate" style="font-size:12px">${escapeHtml(h.path)}</span>
</button>`).join('')
: '<div class="muted xsmall p-2">empty</div>';
host.querySelectorAll('#ax-history [data-hi]').forEach(b => {
b.addEventListener('click', () => {
const it = history.list()[+b.dataset.hi];
if (!it) return;
host.querySelector('#ax-method').value = it.method;
host.querySelector('#ax-path').value = it.path;
host.querySelector('#ax-body').value = it.body || '';
});
});
}
function renderPresets(host) {
host.querySelector('#ax-presets').innerHTML = PRESETS.map((p, i) =>
`<button class="nav-item" data-pi="${i}"><span class="pill info" style="font-size:10px">${p.method}</span><span class="truncate" style="font-size:12px">${escapeHtml(p.label)}</span></button>`
).join('');
host.querySelectorAll('#ax-presets [data-pi]').forEach(b => {
b.addEventListener('click', () => {
const p = PRESETS[+b.dataset.pi];
if (!p) return;
host.querySelector('#ax-method').value = p.method;
host.querySelector('#ax-path').value = p.path;
host.querySelector('#ax-body').value = p.body || '';
});
});
}
async function send(host) {
const method = host.querySelector('#ax-method').value;
const path = host.querySelector('#ax-path').value.trim();
const url = buildUrl(host, path, host.querySelector('#ax-params').value);
const bodyTxt = host.querySelector('#ax-body').value.trim();
const headers = parseKVLines(host.querySelector('#ax-headers').value, ':');
if (!path) return err('path required');
host.querySelector('#ax-status').textContent = '…';
host.querySelector('#ax-resp-meta').textContent = 'sending…';
const t0 = performance.now();
// jraw uses fetch but lib helper doesn't accept custom headers; do it directly for full control.
const init = { method };
const hdr = { Accept: 'application/json' };
if (bodyTxt && method !== 'GET' && method !== 'HEAD') {
hdr['Content-Type'] = 'application/json';
init.body = bodyTxt;
}
for (const [k, v] of headers) hdr[k] = v;
init.headers = hdr;
try {
const r = await fetch(url, init);
const text = await r.text();
let parsed; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
const dt = Math.round(performance.now() - t0);
lastResponse = { status: r.status, body: parsed, raw: text };
lastRequest = { method, url, headers: Object.entries(hdr), body: bodyTxt };
const pill = host.querySelector('#ax-status');
pill.classList.remove('ok', 'warn', 'err', 'muted');
pill.classList.add(r.ok ? 'ok' : (r.status >= 500 ? 'err' : 'warn'));
pill.textContent = `${r.status}`;
host.querySelector('#ax-resp-meta').textContent =
`${method} ${url} · ${dt}ms · ${text.length}b · ${r.headers.get('content-type') || ''}`;
renderResponse(host);
history.push({ method, path, body: bodyTxt, status: r.status });
renderHistory(host);
ok(`${method} ${path}${r.status}`);
} catch (e) {
host.querySelector('#ax-status').textContent = 'ERR';
host.querySelector('#ax-resp-meta').textContent = 'network: ' + e.message;
host.querySelector('#ax-out').textContent = e.message;
err(e.message);
}
}
function applyShareHash(host) {
const m = location.hash.match(/api\?(.*)$/);
if (!m) return;
const p = new URLSearchParams(m[1]);
if (p.get('m')) host.querySelector('#ax-method').value = p.get('m');
if (p.get('p')) host.querySelector('#ax-path').value = p.get('p');
if (p.get('b')) try { host.querySelector('#ax-body').value = atob(p.get('b')); } catch {}
}
registerPane('api', { registerPane('api', {
label: 'API Explorer', label: 'API Explorer',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#ax-send').addEventListener('click', async () => { renderPresets(host);
renderHistory(host);
applyShareHash(host);
host.querySelector('#ax-send').addEventListener('click', () => send(host));
host.querySelector('#ax-path').addEventListener('keydown', e => { if (e.key === 'Enter') send(host); });
// tab switching
host.querySelectorAll('#ax-tabs button').forEach(b => {
b.addEventListener('click', () => {
host.querySelectorAll('#ax-tabs button').forEach(x => x.classList.toggle('active', x === b));
host.querySelectorAll('[data-pane-tab]').forEach(el => el.style.display = el.dataset.paneTab === b.dataset.tab ? '' : 'none');
});
});
host.querySelector('#ax-fmt').addEventListener('click', () => {
const ta = host.querySelector('#ax-body');
try { ta.value = JSON.stringify(JSON.parse(ta.value), null, 2); } catch (e) { err('bad JSON: ' + e.message); }
});
host.querySelector('#ax-copy-body').addEventListener('click', () => copyToClipboard(host.querySelector('#ax-body').value));
host.querySelector('#ax-copy-resp').addEventListener('click', () => copyToClipboard(host.querySelector('#ax-out').textContent));
host.querySelector('#ax-as-curl').addEventListener('click', () => {
const url = buildUrl(host, host.querySelector('#ax-path').value, host.querySelector('#ax-params').value);
const method = host.querySelector('#ax-method').value;
const body = host.querySelector('#ax-body').value;
const headers = parseKVLines(host.querySelector('#ax-headers').value, ':');
copyToClipboard(asCurl({ method, url, body, headers }), 'curl copied');
});
host.querySelector('#ax-resp-pretty').addEventListener('click', () => { prettyMode = !prettyMode; renderResponse(host); });
host.querySelector('#ax-clear-hist').addEventListener('click', () => { history.clear(); renderHistory(host); ok('history cleared'); });
host.querySelector('#ax-share').addEventListener('click', () => {
const m = host.querySelector('#ax-method').value; const m = host.querySelector('#ax-method').value;
const p = host.querySelector('#ax-path').value; const p = host.querySelector('#ax-path').value;
const b = host.querySelector('#ax-body').value.trim(); const b = host.querySelector('#ax-body').value;
const t0 = performance.now(); const usp = new URLSearchParams({ m, p });
try { if (b) usp.set('b', btoa(b));
const r = await jraw(m, p, m === 'GET' ? '' : b); const url = `${location.origin}/#api?${usp.toString()}`;
const dt = Math.round(performance.now() - t0); copyToClipboard(url, 'share link copied');
host.querySelector('#ax-status').textContent = `${r.status} · ${dt}ms · ${r.raw.length}b`;
host.querySelector('#ax-out').textContent = typeof r.body === 'string' ? r.body : fmtJSON(r.body);
} catch (e) {
host.querySelector('#ax-status').textContent = 'error';
host.querySelector('#ax-out').textContent = e.message;
}
});
host.querySelector('#ax-clear').addEventListener('click', () => {
host.querySelector('#ax-out').textContent = '';
host.querySelector('#ax-status').textContent = '—';
}); });
}, },
}); });

View File

@ -1,6 +1,8 @@
// panes/dashboard.js — overview: multi-service health, KPIs, recent items, version. // panes/dashboard.js — control center: KPIs, service health with latency
// sparklines, recent items, live MCP event ticker, quick actions.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, services, probeService, formatUptime, escapeHtml } from '../lib/api.js'; import { jget, services, probeService, formatUptime, escapeHtml, mcp } from '../lib/api.js';
import { sparkline, meterRow, fmtBytes, fmtNum, ok, err, copyToClipboard } from '../lib/ui.js';
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
@ -9,52 +11,71 @@ const TPL = `
<div class="sub">Live status across every CxWebApp service.</div> <div class="sub">Live status across every CxWebApp service.</div>
</div> </div>
<div class="grow"></div> <div class="grow"></div>
<button class="btn btn-secondary" id="db-refresh"> <label class="check"><input type="checkbox" id="db-auto" checked/> auto (15s)</label>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/></svg> <button class="btn btn-secondary" id="db-refresh">Refresh</button>
Refresh
</button>
</div> </div>
<div class="grid cols-4 mb-3" id="db-kpis"></div> <div class="grid cols-4 mb-3" id="db-kpis"></div>
<div class="grid cols-2 mb-3"> <div class="grid cols-2 mb-3">
<div class="card"> <div class="card">
<div class="card-title"><h2>Services</h2><span class="muted mono" id="db-svc-meta"></span></div> <div class="card-title"><h2>Services</h2><span class="muted mono xsmall" id="db-svc-meta"></span></div>
<div id="db-services"></div> <div id="db-services"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Recent items</h2><a href="#items" class="muted">view all </a></div> <div class="card-title"><h2>Resources</h2><span class="muted xsmall" id="db-res-ts"></span></div>
<div id="db-gauges"></div>
<div class="muted xsmall mt-2 mb-1">Probe RTT (last 30 sweeps)</div>
<div id="db-spark"></div>
</div>
</div>
<div class="grid cols-2 mb-3">
<div class="card">
<div class="card-title"><h2>Recent items</h2><a href="#items" class="muted small">view all </a></div>
<div id="db-items"></div> <div id="db-items"></div>
</div> </div>
<div class="card">
<div class="card-title"><h2>Quick actions</h2></div>
<div class="btn-row" style="flex-wrap:wrap">
<a class="btn btn-secondary" href="#api">API Explorer</a>
<a class="btn btn-secondary" href="#tools">MCP Tools</a>
<a class="btn btn-secondary" href="#inbox">Sweep Inbox</a>
<a class="btn btn-secondary" href="#diffusion">Generate Image</a>
<a class="btn btn-secondary" href="#demand">Queue Demand</a>
<a class="btn btn-secondary" href="#lang">Run Pipeline</a>
<button class="btn btn-ghost" id="db-copy">Copy snapshot</button>
</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">MCP event ticker</h2></div>
<pre class="console" id="db-events" style="max-height:24vh"></pre>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Activity</h2><span class="muted mono" id="db-log-meta">stream</span></div> <div class="card-title"><h2>Activity log</h2><span class="muted xsmall" id="db-log-meta">stream</span></div>
<pre class="console" id="db-log">[boot] dashboard ready\n</pre> <pre class="console" id="db-log" style="max-height:30vh">[boot] dashboard ready\n</pre>
</div> </div>
`; `;
function kpi(label, value, sub, accent = '★') { function kpi(label, value, sub, accent = '★') {
return ` return `<div class="kpi">
<div class="kpi"> <div class="label">${escapeHtml(label)}</div>
<div class="label">${escapeHtml(label)}</div> <div class="value">${escapeHtml(value)}</div>
<div class="value">${escapeHtml(value)}</div> <div class="sub">${escapeHtml(sub || '')}</div>
<div class="sub">${escapeHtml(sub || '')}</div> <div class="accent">${accent}</div>
<div class="accent">${accent}</div> </div>`;
</div>
`;
} }
function svcRow(svc, res) { function svcRow(svc, res, history) {
const cls = res.ok ? 'ok' : 'err'; const cls = res.ok ? 'ok' : 'err';
const text = res.ok ? `up · ${res.ms}ms` : `down · ${escapeHtml(String(res.error))}`; const text = res.ok ? `up · ${res.ms}ms` : `down · ${escapeHtml(String(res.error))}`;
return ` return `<div class="row">
<div class="row"> <span class="pill ${cls}"><span class="dot"></span>${escapeHtml(svc.label)}</span>
<span class="pill ${cls}"><span class="dot"></span>${escapeHtml(svc.label)}</span> <div class="grow muted small mono truncate" style="margin-left:8px">${escapeHtml(svc.health)}</div>
<div class="grow"></div> <div style="width:120px">${sparkline(history, { w: 120, h: 22 })}</div>
<span class="mono muted">${text}</span> <span class="mono muted xsmall" style="width:110px;text-align:right">${text}</span>
</div> </div>`;
`;
} }
function log(host, msg) { function log(host, msg) {
@ -65,14 +86,22 @@ function log(host, msg) {
if (pre.textContent.length > 8000) pre.textContent = pre.textContent.slice(0, 8000); if (pre.textContent.length > 8000) pre.textContent = pre.textContent.slice(0, 8000);
} }
const rttHist = {}; // service id -> [last 30 rtts]
let lastSnapshot = {};
function recordRtt(id, ms) {
if (!rttHist[id]) rttHist[id] = [];
rttHist[id].push(ms);
while (rttHist[id].length > 30) rttHist[id].shift();
}
async function refresh(host) { async function refresh(host) {
// probe all services in parallel
const results = await Promise.all(services.map(s => probeService(s).then(r => ({ s, r })))); const results = await Promise.all(services.map(s => probeService(s).then(r => ({ s, r }))));
host.querySelector('#db-services').innerHTML = results.map(({ s, r }) => svcRow(s, r)).join(''); results.forEach(({ s, r }) => recordRtt(s.id, r.ms));
host.querySelector('#db-services').innerHTML = results.map(({ s, r }) => svcRow(s, r, rttHist[s.id])).join('');
const up = results.filter(x => x.r.ok).length; const up = results.filter(x => x.r.ok).length;
host.querySelector('#db-svc-meta').textContent = `${up}/${results.length} up`; host.querySelector('#db-svc-meta').textContent = `${up}/${results.length} up`;
// KPIs from /api/system + /api/items
let sys = {}, ver = {}, itemsResp = { items: [] }; let sys = {}, ver = {}, itemsResp = { items: [] };
try { [sys, ver, itemsResp] = await Promise.all([ try { [sys, ver, itemsResp] = await Promise.all([
jget('/api/system'), jget('/api/version'), jget('/api/items'), jget('/api/system'), jget('/api/version'), jget('/api/items'),
@ -80,36 +109,69 @@ async function refresh(host) {
host.querySelector('#db-kpis').innerHTML = [ host.querySelector('#db-kpis').innerHTML = [
kpi('Health', up === results.length ? 'All up' : `${up}/${results.length}`, kpi('Health', up === results.length ? 'All up' : `${up}/${results.length}`,
up === results.length ? 'all systems normal' : 'some services degraded', '✓'), up === results.length ? 'all systems normal' : 'some degraded', up === results.length ? '✓' : '!'),
kpi('Uptime', formatUptime(sys.uptime_seconds), 'since process boot', '⏱'), kpi('Uptime', formatUptime(sys.uptime_seconds), 'since process boot', '⏱'),
kpi('Version', ver.version || '—', `${(ver.git_sha || '').slice(0, 7) || '—'}`, '⌥'), kpi('Version', ver.version || '—', (ver.git_sha || '').slice(0, 7) || '—', '⌥'),
kpi('Items', String(itemsResp.count ?? (itemsResp.items || []).length), 'stored in API', '☷'), kpi('Items', fmtNum(itemsResp.count ?? (itemsResp.items || []).length), 'stored in API', '☷'),
].join(''); ].join('');
// resource gauges
const cpus = sys.cpu_count || 1;
const load = +sys.loadavg?.[0] || 0;
const memPct = +sys.mem_used_pct || 0;
const loadPct = Math.min(100, load / cpus * 100);
host.querySelector('#db-gauges').innerHTML = [
meterRow('Memory', memPct, `${fmtBytes(sys.rss_bytes)} / ${fmtBytes(sys.mem_total)}`, memPct > 85 ? 'err' : memPct > 60 ? 'warn' : 'ok'),
meterRow('Load 1m', loadPct, load.toFixed(2), loadPct > 80 ? 'err' : loadPct > 50 ? 'warn' : 'ok'),
].join('');
// combined RTT sparkline (averaged across services)
const len = Math.max(0, ...Object.values(rttHist).map(a => a.length));
const avg = Array.from({ length: len }, (_, i) => {
const vals = Object.values(rttHist).map(a => a[i]).filter(v => Number.isFinite(v));
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
});
host.querySelector('#db-spark').innerHTML = sparkline(avg, { w: 480, h: 36 });
host.querySelector('#db-res-ts').textContent = new Date().toLocaleTimeString();
// recent items
const items = (itemsResp.items || []).slice(0, 6); const items = (itemsResp.items || []).slice(0, 6);
host.querySelector('#db-items').innerHTML = items.length host.querySelector('#db-items').innerHTML = items.length
? items.map(it => ` ? items.map(it => `<div class="row">
<div class="row"> <span class="mono muted">#${it.id}</span>
<span class="mono muted">#${it.id}</span> <div class="grow"><div class="ttl">${escapeHtml(it.name)}</div><div class="desc">${escapeHtml(it.description || '')}</div></div>
<div class="grow"> </div>`).join('')
<div class="ttl">${escapeHtml(it.name)}</div>
<div class="desc">${escapeHtml(it.description || '')}</div>
</div>
</div>
`).join('')
: '<div class="muted" style="padding:12px">No items yet.</div>'; : '<div class="muted" style="padding:12px">No items yet.</div>';
// MCP events ticker
try {
const arr = await mcp.events(20);
host.querySelector('#db-events').textContent = (arr || []).slice(-20).reverse().map(ev => {
const t = ev.ts ?? ev.timestamp ?? ev.time;
const time = t ? new Date(typeof t === 'number' ? (t < 1e12 ? t * 1000 : t) : t).toLocaleTimeString() : '—';
return `${time} ${ev.kind || ev.type || 'event'} ${typeof ev.detail === 'string' ? ev.detail : JSON.stringify(ev.detail ?? ev.data ?? '')}`;
}).join('\n') || '(no events)';
} catch (e) {
host.querySelector('#db-events').textContent = `(events unavailable: ${e.message})`;
}
lastSnapshot = { sys, ver, services: results.map(({ s, r }) => ({ id: s.id, ok: r.ok, ms: r.ms })) };
log(host, `refreshed · ${up}/${results.length} services up`); log(host, `refreshed · ${up}/${results.length} services up`);
} }
let timer = null;
registerPane('dashboard', { registerPane('dashboard', {
label: 'Dashboard', label: 'Dashboard',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#db-refresh').addEventListener('click', () => refresh(host)); host.querySelector('#db-refresh').addEventListener('click', () => refresh(host));
host.querySelector('#db-copy').addEventListener('click', () => copyToClipboard(JSON.stringify(lastSnapshot, null, 2), 'snapshot copied'));
refresh(host); refresh(host);
// auto-refresh every 15s while pane is visible timer = setInterval(() => {
setInterval(() => { if (host.classList.contains('active')) refresh(host); }, 15_000); if (!host.classList.contains('active')) return;
if (!host.querySelector('#db-auto')?.checked) return;
refresh(host);
}, 15_000);
}, },
refresh, refresh,
}); });

View File

@ -1,23 +1,37 @@
// panes/demand.js — cxai-demand jobs. // panes/demand.js — cxai-demand jobs with presets, live polling, report viewer.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, jpost, escapeHtml, statusClass } from '../lib/api.js'; import { jget, jpost, escapeHtml, statusClass } from '../lib/api.js';
import { ok, err, fmtJSON } from '../lib/ui.js'; import { ok, err, fmtJSON, copyToClipboard } from '../lib/ui.js';
const PRESETS = {
lite: { designs_per_run: 1, top_trends: 5, variations_per_trend: 1, min_score: 0.7, quantum: false },
normal: { designs_per_run: 3, top_trends: 10, variations_per_trend: 2, min_score: 0.6, quantum: false },
aggressive: { designs_per_run: 10,top_trends: 25, variations_per_trend: 4, min_score: 0.4, quantum: true },
};
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">Demand</div><div class="sub">Trend-driven design jobs.</div></div> <div><div class="title">Demand</div><div class="sub">Trend-driven design jobs.</div></div>
<div class="grow"></div> <div class="grow"></div>
<label class="check"><input type="checkbox" id="demand-auto" checked/> auto-refresh (5s)</label>
<button class="btn btn-secondary" id="demand-refresh">Refresh</button> <button class="btn btn-secondary" id="demand-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2"> <div class="grid cols-4 mb-3" id="demand-kpis"></div>
<div class="grid cols-2 mb-3">
<div class="card"> <div class="card">
<div class="card-title"><h2>Run config</h2><span class="muted" id="demand-status"></span></div> <div class="card-title"><h2>Run config</h2><span class="muted xsmall" id="demand-status"></span></div>
<div class="chips mb-3">
<button class="chip" data-preset="lite" type="button">Lite</button>
<button class="chip" data-preset="normal" type="button">Normal</button>
<button class="chip" data-preset="aggressive" type="button">Aggressive</button>
</div>
<form id="demand-form" class="grid cols-2 gap-3"> <form id="demand-form" class="grid cols-2 gap-3">
<label class="field"><span class="lbl">Designs per run</span><input class="input" id="demand-count" type="number" min="1" value="3"/></label> <label class="field"><span class="lbl">Designs / run</span><input class="input" id="demand-count" type="number" min="1" value="3"/></label>
<label class="field"><span class="lbl">Top trends</span><input class="input" id="demand-top" type="number"/></label> <label class="field"><span class="lbl">Top trends</span><input class="input" id="demand-top" type="number" placeholder="—"/></label>
<label class="field"><span class="lbl">Variations / trend</span><input class="input" id="demand-var" type="number"/></label> <label class="field"><span class="lbl">Variations / trend</span><input class="input" id="demand-var" type="number" placeholder="—"/></label>
<label class="field"><span class="lbl">Min score</span><input class="input" id="demand-min" type="number" step="0.01"/></label> <label class="field"><span class="lbl">Min score</span><input class="input" id="demand-min" type="number" step="0.01" placeholder="—"/></label>
<label class="field" style="grid-column: span 2"> <label class="field" style="grid-column: span 2">
<span class="lbl">Platform</span> <span class="lbl">Platform</span>
<select class="input" id="demand-platform"></select> <select class="input" id="demand-platform"></select>
@ -26,58 +40,97 @@ const TPL = `
<label class="check"><input type="checkbox" id="demand-up"/> upload</label> <label class="check"><input type="checkbox" id="demand-up"/> upload</label>
<label class="check"><input type="checkbox" id="demand-web"/> web trends</label> <label class="check"><input type="checkbox" id="demand-web"/> web trends</label>
<label class="check"><input type="checkbox" id="demand-q"/> quantum</label> <label class="check"><input type="checkbox" id="demand-q"/> quantum</label>
<button class="btn btn-primary" type="submit" style="grid-column: span 2">Queue run</button> <div class="btn-row" style="grid-column: span 2">
<button class="btn btn-primary" type="submit">Queue run</button>
<button class="btn btn-ghost" type="reset">Clear</button>
</div>
</form> </form>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Jobs</h2></div> <div class="card-title"><h2>Jobs</h2><span class="muted xsmall" id="demand-job-meta"></span></div>
<div id="demand-jobs" class="scroll" style="max-height:50vh"></div> <div id="demand-jobs" class="scroll" style="max-height:54vh"></div>
</div> </div>
</div> </div>
<div class="card mt-3"> <div class="card">
<div class="card-title"><h2>Reports</h2></div> <div class="card-title"><h2>Reports</h2>
<div class="flex gap-2"><input class="input" id="demand-rfilter" placeholder="filter…" style="max-width:200px"/>
<button class="btn btn-ghost" id="demand-rcopy">Copy report</button></div>
</div>
<div class="grid cols-2 gap-3"> <div class="grid cols-2 gap-3">
<div id="demand-reports" class="scroll" style="max-height:40vh"></div> <div id="demand-reports" class="scroll" style="max-height:46vh"></div>
<pre id="demand-report-body" class="muted">select a report</pre> <pre id="demand-report-body" class="code muted" style="max-height:46vh">select a report</pre>
</div> </div>
</div> </div>
`; `;
function kpi(label, value, sub) {
return `<div class="kpi"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(String(value))}</div><div class="sub">${escapeHtml(sub || '')}</div></div>`;
}
function num(v) { if (v === '' || v == null) return null; const n = +v; return Number.isFinite(n) ? n : null; } function num(v) { if (v === '' || v == null) return null; const n = +v; return Number.isFinite(n) ? n : null; }
let allReports = [];
async function loadJobs(host) { async function loadJobs(host) {
try { try {
const r = await jget('/api/demand/runs'); const r = await jget('/api/demand/runs');
host.querySelector('#demand-jobs').innerHTML = (r.jobs || []).map(j => { const jobs = r.jobs || [];
host.querySelector('#demand-job-meta').textContent = `${jobs.length} total`;
host.querySelector('#demand-jobs').innerHTML = jobs.length ? jobs.map(j => {
const dur = j.finished_at && j.started_at ? `${(j.finished_at - j.started_at).toFixed(1)}s` const dur = j.finished_at && j.started_at ? `${(j.finished_at - j.started_at).toFixed(1)}s`
: (j.status === 'running' ? '…' : '—'); : (j.status === 'running' ? '…' : '—');
const cancelBtn = j.status === 'running'
? `<button class="act" data-cancel="${escapeHtml(j.id)}">cancel</button>` : '';
return `<div class="row"> return `<div class="row">
<span class="mono muted">${escapeHtml(j.id)}</span> <span class="mono muted xsmall">${escapeHtml(j.id)}</span>
<span class="pill ${statusClass(j.status)}">${escapeHtml(j.status)}</span> <span class="pill ${statusClass(j.status)}">${escapeHtml(j.status)}</span>
<span class="muted mono">${dur}</span> <span class="muted mono xsmall">${dur}</span>
<div class="grow desc">${escapeHtml(j.report_path || j.error || '')}</div> <div class="grow desc truncate">${escapeHtml(j.report_path || j.error || '')}</div>
${cancelBtn}
</div>`; </div>`;
}).join('') || '<div class="muted" style="padding:12px">no jobs yet</div>'; }).join('') : '<div class="muted" style="padding:12px">no jobs yet</div>';
host.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => {
try { await jpost(`/api/demand/runs/${b.dataset.cancel}/cancel`, {}); ok('cancel requested'); loadJobs(host); }
catch (e) { err(e.message); }
}));
const running = jobs.filter(j => j.status === 'running').length;
const success = jobs.filter(j => j.status === 'success' || j.status === 'completed').length;
const failed = jobs.filter(j => (j.status || '').includes('fail') || (j.status || '').includes('error')).length;
host.querySelector('#demand-kpis').innerHTML = [
kpi('Jobs', jobs.length, 'all time'),
kpi('Running', running, 'now'),
kpi('Success', success, 'completed'),
kpi('Failed', failed, 'errors'),
].join('');
} catch (e) { } catch (e) {
host.querySelector('#demand-jobs').innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`; host.querySelector('#demand-jobs').innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`;
} }
} }
function renderReports(host) {
const q = host.querySelector('#demand-rfilter').value.trim().toLowerCase();
const items = allReports.filter(it => !q || it.name.toLowerCase().includes(q));
host.querySelector('#demand-reports').innerHTML = items.length
? items.map(it => `<div class="row">
<a href="#" data-name="${escapeHtml(it.name)}" class="report-link grow truncate">${escapeHtml(it.name)}</a>
<span class="muted mono xsmall">${it.size}b</span>
</div>`).join('')
: '<div class="muted xsmall p-2">no reports</div>';
host.querySelectorAll('.report-link').forEach(b => b.addEventListener('click', async (e) => {
e.preventDefault();
try { host.querySelector('#demand-report-body').textContent = fmtJSON(await jget('/api/demand/reports/' + b.dataset.name)); }
catch (err2) { host.querySelector('#demand-report-body').textContent = err2.message; }
}));
}
async function loadReports(host) { async function loadReports(host) {
try { try {
const r = await jget('/api/demand/reports'); const r = await jget('/api/demand/reports');
host.querySelector('#demand-reports').innerHTML = (r.reports || []).map(it => allReports = r.reports || [];
`<div class="row"><a href="#" data-name="${escapeHtml(it.name)}" class="report-link grow">${escapeHtml(it.name)}</a><span class="muted mono">${it.size}b</span></div>` renderReports(host);
).join('') || '<div class="muted" style="padding:12px">no reports</div>';
host.querySelectorAll('.report-link').forEach(b => {
b.addEventListener('click', async (e) => {
e.preventDefault();
try { host.querySelector('#demand-report-body').textContent = fmtJSON(await jget('/api/demand/reports/' + b.dataset.name)); }
catch (e) { host.querySelector('#demand-report-body').textContent = e.message; }
});
});
} catch (e) { } catch (e) {
host.querySelector('#demand-reports').innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`; host.querySelector('#demand-reports').innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`;
} }
@ -96,11 +149,24 @@ async function refresh(host) {
} }
} }
let timer = null;
registerPane('demand', { registerPane('demand', {
label: 'Demand', label: 'Demand',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#demand-refresh').addEventListener('click', () => refresh(host)); host.querySelector('#demand-refresh').addEventListener('click', () => refresh(host));
host.querySelector('#demand-rfilter').addEventListener('input', () => renderReports(host));
host.querySelector('#demand-rcopy').addEventListener('click', () => copyToClipboard(host.querySelector('#demand-report-body').textContent));
host.querySelectorAll('[data-preset]').forEach(b => b.addEventListener('click', () => {
const p = PRESETS[b.dataset.preset]; if (!p) return;
host.querySelector('#demand-count').value = p.designs_per_run;
host.querySelector('#demand-top').value = p.top_trends;
host.querySelector('#demand-var').value = p.variations_per_trend;
host.querySelector('#demand-min').value = p.min_score;
host.querySelector('#demand-q').checked = p.quantum;
}));
host.querySelector('#demand-form').addEventListener('submit', async (e) => { host.querySelector('#demand-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const body = { const body = {
@ -121,7 +187,13 @@ registerPane('demand', {
await loadJobs(host); await loadJobs(host);
} catch (e) { host.querySelector('#demand-status').textContent = 'error: ' + e.message; err(e.message); } } catch (e) { host.querySelector('#demand-status').textContent = 'error: ' + e.message; err(e.message); }
}); });
refresh(host); refresh(host);
timer = setInterval(() => {
if (!host.classList.contains('active')) return;
if (!host.querySelector('#demand-auto')?.checked) return;
loadJobs(host);
}, 5000);
}, },
refresh, refresh,
}); });

View File

@ -1,7 +1,17 @@
// panes/diffusion.js — Stable Diffusion proxy. // panes/diffusion.js — Stable Diffusion proxy with prompt presets, model picker, gallery history.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, jpost, escapeHtml } from '../lib/api.js'; import { jget, jpost, escapeHtml } from '../lib/api.js';
import { fmtJSON, ok, err } from '../lib/ui.js'; import { fmtJSON, ok, err, copyToClipboard, historyStore } from '../lib/ui.js';
const galleryHist = historyStore('cx_diff_gallery', 12);
const PROMPT_PRESETS = [
{ label: 'Portrait', prompt: 'a cinematic portrait of a person, soft natural light, 50mm lens, bokeh, photorealistic, sharp focus', neg: 'blurry, deformed, text, watermark' },
{ label: 'Landscape', prompt: 'sweeping mountain landscape at golden hour, dramatic clouds, ultra-detailed, 8k, photoreal', neg: 'low quality, jpeg artifacts, signature' },
{ label: 'Anime', prompt: 'anime illustration, vibrant colors, detailed face, dynamic pose, by Makoto Shinkai, masterpiece', neg: 'realistic, blurry, lowres, bad anatomy' },
{ label: 'Photoreal', prompt: 'photoreal product shot, studio lighting, white background, high detail, hyperreal', neg: 'illustration, painting, low quality' },
{ label: 'Cyberpunk', prompt: 'cyberpunk street, neon signs, rain, reflective puddles, futuristic, cinematic lighting', neg: 'daylight, bright, cartoon' },
];
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
@ -10,85 +20,155 @@ const TPL = `
<span class="pill" id="diff-pill"><span class="dot"></span><span id="diff-pill-text"></span></span> <span class="pill" id="diff-pill"><span class="dot"></span><span id="diff-pill-text"></span></span>
</div> </div>
<div class="grid cols-2"> <div class="grid cols-2 mb-3">
<div class="card"> <div class="card">
<div class="card-title"><h2>Prompt</h2></div> <div class="card-title"><h2>Prompt</h2></div>
<div class="chips mb-3" id="diff-presets"></div>
<form id="diff-form" class="grid gap-3"> <form id="diff-form" class="grid gap-3">
<label class="field"><span class="lbl">Prompt</span><textarea class="input" id="diff-prompt" rows="3" required></textarea></label> <label class="field"><span class="lbl">Prompt</span><textarea class="input" id="diff-prompt" rows="3" required></textarea></label>
<label class="field"><span class="lbl">Negative prompt</span><textarea class="input" id="diff-neg" rows="2"></textarea></label> <label class="field"><span class="lbl">Negative prompt</span><textarea class="input" id="diff-neg" rows="2"></textarea></label>
<div class="grid cols-3 gap-3"> <div class="grid cols-3 gap-3">
<label class="field"><span class="lbl">Steps</span><input class="input" id="diff-steps" type="number" value="20" min="1"/></label> <label class="field"><span class="lbl">Steps</span><input class="input" id="diff-steps" type="number" value="20" min="1"/></label>
<label class="field"><span class="lbl">CFG</span><input class="input" id="diff-cfg" type="number" value="7" step="0.5"/></label> <label class="field"><span class="lbl">CFG</span><input class="input" id="diff-cfg" type="number" value="7" step="0.5"/></label>
<label class="field"><span class="lbl">Sampler</span><input class="input" id="diff-sampler" placeholder="(optional)"/></label> <label class="field"><span class="lbl">Seed</span><input class="input" id="diff-seed" type="number" value="-1" title="-1 = random"/></label>
</div> </div>
<div class="grid cols-2 gap-3"> <div class="grid cols-3 gap-3">
<label class="field"><span class="lbl">Width</span><input class="input" id="diff-w" type="number" value="512" step="64"/></label> <label class="field"><span class="lbl">Width</span><input class="input" id="diff-w" type="number" value="512" step="64"/></label>
<label class="field"><span class="lbl">Height</span><input class="input" id="diff-h" type="number" value="512" step="64"/></label> <label class="field"><span class="lbl">Height</span><input class="input" id="diff-h" type="number" value="512" step="64"/></label>
<label class="field"><span class="lbl">Batch</span><input class="input" id="diff-batch" type="number" value="1" min="1" max="8"/></label>
</div>
<div class="grid cols-2 gap-3">
<label class="field"><span class="lbl">Model</span><select class="input" id="diff-model"><option value="">(default)</option></select></label>
<label class="field"><span class="lbl">Sampler</span><select class="input" id="diff-sampler"><option value="">(default)</option></select></label>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-primary" type="submit">Generate</button> <button class="btn btn-primary" type="submit" id="diff-submit">Generate</button>
<button class="btn btn-secondary" type="button" id="diff-models">Models</button>
<button class="btn btn-secondary" type="button" id="diff-samplers">Samplers</button>
<button class="btn btn-secondary" type="button" id="diff-progress">Progress</button> <button class="btn btn-secondary" type="button" id="diff-progress">Progress</button>
<button class="btn btn-ghost" type="reset">Reset</button>
</div> </div>
</form> </form>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Output</h2><span class="muted mono" id="diff-status"></span></div> <div class="card-title"><h2>Output</h2><span class="muted mono xsmall" id="diff-status"></span></div>
<div class="gallery" id="diff-gallery"></div> <div class="gallery" id="diff-gallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px"></div>
<h3 class="mt-3 mb-2">Meta</h3> <div class="divider"></div>
<pre id="diff-meta"></pre> <div class="card-title"><h2 style="font-size:14px">Meta</h2><button class="btn btn-ghost" id="diff-copy-meta">Copy</button></div>
<pre id="diff-meta" class="code" style="max-height:18vh"></pre>
</div> </div>
</div> </div>
<div class="card">
<div class="card-title"><h2>Recent generations</h2><button class="btn btn-ghost" id="diff-clear-hist">Clear</button></div>
<div id="diff-history" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px"></div>
</div>
`; `;
async function probe(host) { async function probe(host) {
const pill = host.querySelector('#diff-pill');
try { try {
const h = await jget('/api/diffusion/healthz'); const h = await jget('/api/diffusion/healthz');
host.querySelector('#diff-pill').classList.add('ok'); pill.classList.remove('err'); pill.classList.add('ok');
host.querySelector('#diff-pill-text').textContent = `up · ${h.backend ?? '?'}`; host.querySelector('#diff-pill-text').textContent = `up · ${h.backend ?? '?'}`;
} catch (e) { } catch (e) {
host.querySelector('#diff-pill').classList.add('err'); pill.classList.remove('ok'); pill.classList.add('err');
host.querySelector('#diff-pill-text').textContent = 'down'; host.querySelector('#diff-pill-text').textContent = 'down';
} }
} }
async function loadModels(host) {
try {
const m = await jget('/api/diffusion/v1/models');
const sel = host.querySelector('#diff-model');
(m.models || m || []).forEach(x => {
const name = x.model_name || x.name || x.id || x;
sel.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(String(name))}">${escapeHtml(String(name))}</option>`);
});
} catch {}
try {
const s = await jget('/api/diffusion/v1/samplers');
const sel = host.querySelector('#diff-sampler');
(s.samplers || s || []).forEach(x => {
const n = x.name || x;
sel.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(String(n))}">${escapeHtml(String(n))}</option>`);
});
} catch {}
}
function renderHistory(host) {
const items = galleryHist.list();
host.querySelector('#diff-history').innerHTML = items.length
? items.map((g, i) => `<button class="card no-pad" data-hi="${i}" title="${escapeHtml(g.prompt)}" style="border:1px solid var(--border);cursor:pointer;padding:0">
<img src="data:image/png;base64,${g.b64}" style="width:100%;display:block;border-radius:4px"/>
<div class="p-2 truncate muted xsmall">${escapeHtml(g.prompt)}</div>
</button>`).join('')
: '<div class="muted xsmall p-2">no history yet</div>';
host.querySelectorAll('#diff-history [data-hi]').forEach(b => b.addEventListener('click', () => {
const g = galleryHist.list()[+b.dataset.hi];
if (!g) return;
host.querySelector('#diff-prompt').value = g.prompt;
host.querySelector('#diff-neg').value = g.neg || '';
if (g.seed != null) host.querySelector('#diff-seed').value = g.seed;
}));
}
registerPane('diffusion', { registerPane('diffusion', {
label: 'Diffusion', label: 'Diffusion',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
probe(host); host.querySelector('#diff-presets').innerHTML = PROMPT_PRESETS.map((p, i) =>
`<button class="chip" data-pi="${i}" type="button">${escapeHtml(p.label)}</button>`).join('');
host.querySelectorAll('#diff-presets .chip').forEach(b => b.addEventListener('click', () => {
const p = PROMPT_PRESETS[+b.dataset.pi];
host.querySelector('#diff-prompt').value = p.prompt;
host.querySelector('#diff-neg').value = p.neg;
}));
probe(host); loadModels(host); renderHistory(host);
host.querySelector('#diff-form').addEventListener('submit', async (e) => { host.querySelector('#diff-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const btn = host.querySelector('#diff-submit'); btn.disabled = true;
host.querySelector('#diff-status').textContent = 'generating…'; host.querySelector('#diff-status').textContent = 'generating…';
const seed = +host.querySelector('#diff-seed').value;
const body = { const body = {
prompt: host.querySelector('#diff-prompt').value, prompt: host.querySelector('#diff-prompt').value,
negative_prompt: host.querySelector('#diff-neg').value, negative_prompt: host.querySelector('#diff-neg').value,
steps: +host.querySelector('#diff-steps').value || 20, steps: +host.querySelector('#diff-steps').value || 20,
width: +host.querySelector('#diff-w').value || 512, width: +host.querySelector('#diff-w').value || 512,
height: +host.querySelector('#diff-h').value || 512, height: +host.querySelector('#diff-h').value || 512,
cfg_scale: +host.querySelector('#diff-cfg').value || 7, cfg_scale: +host.querySelector('#diff-cfg').value || 7,
batch_size: +host.querySelector('#diff-batch').value || 1,
}; };
const s = host.querySelector('#diff-sampler').value.trim(); if (seed >= 0) body.seed = seed;
if (s) body.sampler_name = s; const sm = host.querySelector('#diff-sampler').value; if (sm) body.sampler_name = sm;
const md = host.querySelector('#diff-model').value; if (md) body.model = md;
try { try {
const r = await jpost('/api/diffusion/v1/generate', body); const r = await jpost('/api/diffusion/v1/generate', body);
host.querySelector('#diff-gallery').innerHTML = (r.images || []).map(b64 => host.querySelector('#diff-gallery').innerHTML = (r.images || []).map((b64, i) =>
`<img src="data:image/png;base64,${b64}" alt="generated" />` `<a download="cxai-diff-${Date.now()}-${i}.png" href="data:image/png;base64,${b64}"><img src="data:image/png;base64,${b64}" alt="generated" style="width:100%;border-radius:4px;display:block"/></a>`
).join(''); ).join('');
host.querySelector('#diff-meta').textContent = fmtJSON({ seeds: r.seeds, duration_s: r.duration_s }); host.querySelector('#diff-meta').textContent = fmtJSON({ seeds: r.seeds, duration_s: r.duration_s, model: r.model, sampler: r.sampler });
host.querySelector('#diff-status').textContent = `ok · ${r.duration_s?.toFixed?.(2) ?? '?'}s · ${(r.images||[]).length} img`; host.querySelector('#diff-status').textContent = `ok · ${r.duration_s?.toFixed?.(2) ?? '?'}s · ${(r.images || []).length} img`;
(r.images || []).forEach((b64, i) => galleryHist.push({
b64, prompt: body.prompt, neg: body.negative_prompt, seed: r.seeds?.[i],
}));
renderHistory(host);
ok('Generated'); ok('Generated');
} catch (e) { } catch (e) {
host.querySelector('#diff-status').textContent = `error ${e.status ?? ''}`; host.querySelector('#diff-status').textContent = `error ${e.status ?? ''}`;
host.querySelector('#diff-meta').textContent = fmtJSON(e.body ?? { error: e.message }); host.querySelector('#diff-meta').textContent = fmtJSON(e.body ?? { error: e.message });
err(e.message); err(e.message);
} } finally { btn.disabled = false; }
});
host.querySelector('#diff-progress').addEventListener('click', async () => {
try { host.querySelector('#diff-meta').textContent = fmtJSON(await jget('/api/diffusion/v1/progress')); }
catch (e) { host.querySelector('#diff-meta').textContent = e.message; }
});
host.querySelector('#diff-copy-meta').addEventListener('click', () => copyToClipboard(host.querySelector('#diff-meta').textContent));
host.querySelector('#diff-clear-hist').addEventListener('click', () => {
if (!confirm('Clear gallery history?')) return;
galleryHist.clear(); renderHistory(host);
}); });
const meta = host.querySelector('#diff-meta');
const show = async (url) => { try { meta.textContent = fmtJSON(await jget(url)); } catch (e) { meta.textContent = e.message; } };
host.querySelector('#diff-models').addEventListener('click', () => show('/api/diffusion/v1/models'));
host.querySelector('#diff-samplers').addEventListener('click', () => show('/api/diffusion/v1/samplers'));
host.querySelector('#diff-progress').addEventListener('click', () => show('/api/diffusion/v1/progress'));
}, },
}); });

236
static/js/panes/files.js Normal file
View File

@ -0,0 +1,236 @@
// panes/files.js — Surfaces the files.cxllm.io platform data (Providers /
// Edge Functions / Demand / Watcher / Health / File-manager) directly inside
// CxWebApp. All requests go through the C++ backend at /api/files/* which
// injects the PostgREST anon key so the browser never sees the secret.
import { jget, jpost, escapeHtml } from '../lib/api.js';
import { ok, err } from '../lib/ui.js';
import { registerPane } from '../app.js';
const TABS = [
{ id: 'providers', label: 'Providers', endpoint: '/mcp_providers?select=*&order=id.asc' },
{ id: 'functions', label: 'Edge Functions', endpoint: '/platform_functions?select=*&order=slug.asc' },
{ id: 'health', label: 'Health', endpoint: '/platform_health?select=*&order=captured_at.desc&limit=20' },
{ id: 'demand', label: 'Demand Runs', endpoint: '/demand_runs?select=*&order=received_at.desc&limit=20' },
{ id: 'watcher', label: 'Watcher Events', endpoint: '/watcher_events?select=*&order=occurred_at.desc&limit=50' },
{ id: 'categories', label: 'File Manager', endpoint: '/file_manager_categories?select=*&order=category.asc' },
];
let activeTab = 'providers';
const rest = (path) => jget(`/api/files${path}`);
const restPost = (path, body) => jpost(`/api/files${path}`, body, { headers: { Prefer: 'return=minimal' } });
const TPL = `
<div class="pane-head">
<div>
<div class="title">Files · Platform</div>
<div class="sub">Live providers, edge functions, demand, watcher and health from <code>files.cxllm.io</code>.</div>
</div>
<div class="grow"></div>
<a class="btn btn-secondary" href="https://files.cxllm.io" target="_blank" rel="noopener">Open Studio </a>
<button class="btn btn-secondary" id="files-refresh">Refresh</button>
</div>
<div class="card mb-3">
<div class="tabs" id="files-tabs">
${TABS.map(t => `<button class="tab" data-tab="${t.id}">${escapeHtml(t.label)}</button>`).join('')}
</div>
</div>
<div id="files-body"></div>
`;
function tag(kind, txt) {
return `<span class="pill ${kind}">${escapeHtml(txt ?? '')}</span>`;
}
function statusPill(s) {
if (!s) return tag('muted', '—');
const v = String(s).toLowerCase();
if (['ok', 'ready', 'healthy', 'success'].includes(v)) return tag('ok', s);
if (['degraded', 'warn', 'pending', 'running'].includes(v)) return tag('warn', s);
if (['down', 'failed', 'error'].includes(v)) return tag('err', s);
return tag('info', s);
}
function ts(s) { return escapeHtml((s || '').replace('T', ' ').slice(0, 19)); }
// ---- renderers ----
function renderProviders(rows) {
if (!rows.length) return `<div class="card"><div class="muted">No providers configured.</div></div>`;
return `<div class="grid cols-2">${rows.map(p => `
<div class="card">
<div class="card-title"><h2>${escapeHtml(p.name || p.id)}</h2>${statusPill(p.status)}</div>
<div class="muted mb-2">${escapeHtml(p.description || '')}</div>
<pre class="mono small">id: ${escapeHtml(p.id || '')}
model: ${escapeHtml(p.default_model || '-')}
source: ${escapeHtml(p.source_file || '')}
base: ${escapeHtml(p.base_url || '-')}
env: ${(p.requires_env || []).join(', ') || '(none)'}</pre>
</div>`).join('')}</div>`;
}
function renderFunctions(rows) {
if (!rows.length) return `<div class="card"><div class="muted">No edge functions registered.</div></div>`;
return `<div class="grid cols-2">${rows.map(f => `
<div class="card">
<div class="card-title"><h2>${escapeHtml(f.title || f.slug)}</h2>${tag('info', `${f.method || 'GET'} · ${f.runtime || ''}`)}</div>
<div class="muted mb-2">${escapeHtml(f.description || '')}</div>
<pre class="mono small">route: ${escapeHtml(f.route || '')}
source: ${escapeHtml(f.source_file || '')}
env: ${(f.requires_env || []).join(', ') || '(none)'}</pre>
</div>`).join('')}</div>`;
}
function renderHealth(rows) {
return `
<div class="card mb-3">
<div class="row">
<button class="btn" id="files-capture-health">Capture snapshot</button>
<span class="muted ml-2">Probes Postgres / MCP / HF / Gitea from the browser, then inserts a row.</span>
</div>
</div>
<div class="card">
<div class="card-title"><h2>Recent snapshots</h2><span class="muted mono">${rows.length} rows</span></div>
${rows.length === 0 ? '<div class="muted">No snapshots yet.</div>' : `
<table class="table">
<thead><tr><th>captured</th><th>overall</th><th>ms</th><th>pg</th><th>mcp</th><th>hf</th><th>gitea</th></tr></thead>
<tbody>${rows.map(r => `
<tr>
<td class="mono small">${ts(r.captured_at)}</td>
<td>${statusPill(r.overall_status)}</td>
<td class="mono">${r.total_latency_ms ?? ''}</td>
<td>${r.supabase?.ok ? '✓' : '·'}</td>
<td>${r.mcp?.ok ? '✓' : '·'}</td>
<td>${r.huggingface?.ok ? '✓' : '·'}</td>
<td>${r.gitea?.ok ? '✓' : '·'}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>`;
}
function renderDemand(rows) {
if (!rows.length) return `<div class="card"><div class="muted">No demand runs received. Configure <code>CXCLOUD_MCP_URL</code> in <code>cxai-demand</code>.</div></div>`;
return `<div class="card">
<table class="table">
<thead><tr><th>received</th><th>date</th><th>mode</th><th>designs</th><th>ok</th><th>rej</th><th>quality</th><th>platforms</th></tr></thead>
<tbody>${rows.map(r => `
<tr>
<td class="mono small">${ts(r.received_at)}</td>
<td>${escapeHtml(r.run_date || '')}</td>
<td>${r.dry_run ? tag('warn', 'dry') : tag('ok', 'live')}</td>
<td class="mono">${r.total_designs || 0}</td>
<td class="mono ok">${r.accepted_designs || 0}</td>
<td class="mono err">${r.rejected_designs || 0}</td>
<td class="mono">${r.average_quality_score != null ? Number(r.average_quality_score).toFixed(2) : '—'}</td>
<td class="muted small">${escapeHtml(Object.keys(r.by_platform || {}).join(', ') || '—')}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderWatcher(rows) {
if (!rows.length) return `<div class="card"><div class="muted">No watcher events yet.</div></div>`;
return `<div class="card">
<table class="table">
<thead><tr><th>when</th><th>kind</th><th>path</th><th>tool</th><th>status</th><th>ms</th></tr></thead>
<tbody>${rows.map(e => `
<tr>
<td class="mono small">${escapeHtml((e.occurred_at || '').replace('T', ' ').slice(11, 19))}</td>
<td>${tag(e.event_kind === 'created' ? 'ok' : e.event_kind === 'removed' ? 'err' : 'info', e.event_kind)}</td>
<td class="mono small">${escapeHtml(e.path || '')}</td>
<td class="muted small">${escapeHtml(e.action_tool || '-')}</td>
<td>${statusPill(e.status)}</td>
<td class="mono">${e.latency_ms ?? ''}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderCategories(rows) {
if (!rows.length) return `<div class="card"><div class="muted">No file-manager categories.</div></div>`;
return `<div class="grid cols-2">${rows.map(c => `
<div class="card">
<div class="card-title"><h2>${escapeHtml(c.category || '')}</h2><span class="muted mono">${(c.extensions || []).length} ext</span></div>
<div class="small">${(c.extensions || []).map(x => `<span class="pill info">.${escapeHtml(x)}</span>`).join(' ')}</div>
</div>`).join('')}</div>`;
}
const RENDERERS = {
providers: renderProviders,
functions: renderFunctions,
health: renderHealth,
demand: renderDemand,
watcher: renderWatcher,
categories: renderCategories,
};
async function loadTab(host, tabId) {
activeTab = tabId;
const tab = TABS.find(t => t.id === tabId);
const body = host.querySelector('#files-body');
body.innerHTML = `<div class="card"><div class="muted">Loading ${escapeHtml(tab.label)}…</div></div>`;
host.querySelectorAll('#files-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId));
try {
const data = await rest(tab.endpoint);
const rows = Array.isArray(data) ? data : [];
body.innerHTML = RENDERERS[tabId](rows);
bindActions(host);
} catch (e) {
body.innerHTML = `<div class="card">
<div class="card-title"><h2>Failed to load ${escapeHtml(tab.label)}</h2></div>
<pre class="mono small">${escapeHtml(e.message || String(e))}</pre>
<div class="muted small">Confirm <code>CXAI_FILES_UPSTREAM</code> and <code>FILES_ANON_KEY</code> are set on the backend.</div>
</div>`;
}
}
function bindActions(host) {
const capture = host.querySelector('#files-capture-health');
if (!capture) return;
capture.onclick = async () => {
capture.disabled = true; capture.textContent = 'Capturing…';
const t0 = performance.now();
const probe = async (name, fn) => {
const s = performance.now();
try { const r = await fn(); return { name, ok: !!r, latency_ms: Math.round(performance.now() - s) }; }
catch { return { name, ok: false, latency_ms: Math.round(performance.now() - s) }; }
};
const probes = await Promise.all([
probe('supabase', () => rest('/mcp_providers?select=id&limit=1')),
probe('mcp', () => fetch('/api/health').then(r => r.ok)),
probe('huggingface', () => fetch('https://router.huggingface.co/', { mode: 'no-cors' })),
probe('gitea', () => fetch('https://cxai-studio.com/git/api/v1/version').then(r => r.ok)),
]);
const okCount = probes.filter(p => p.ok).length;
const overall = okCount === 4 ? 'ok' : okCount >= 2 ? 'degraded' : 'down';
try {
await restPost('/platform_health', {
overall_status: overall,
total_latency_ms: Math.round(performance.now() - t0),
supabase: probes[0], mcp: probes[1], huggingface: probes[2], gitea: probes[3],
raw: { source: 'cxwebapp', probes },
});
ok('Snapshot captured', 'Files');
} catch (e) {
err(`Capture failed: ${e.message}`, 'Files');
}
await loadTab(host, 'health');
};
}
registerPane('files', {
label: 'Files · Platform',
async init(host) {
host.innerHTML = TPL;
host.querySelectorAll('#files-tabs .tab').forEach(b => {
b.addEventListener('click', () => loadTab(host, b.dataset.tab));
});
host.querySelector('#files-refresh').addEventListener('click', () => loadTab(host, activeTab));
await loadTab(host, activeTab);
},
async refresh(host) {
await loadTab(host, activeTab);
},
});

View File

@ -1,21 +1,23 @@
// panes/inbox.js — sweep & route CxAI/_inbox via MCP tools. // panes/inbox.js — sweep & route CxAI/_inbox via MCP tools with slider, history, presets.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { mcp, escapeHtml } from '../lib/api.js'; import { mcp, escapeHtml } from '../lib/api.js';
import { ok, err, fmtJSON } from '../lib/ui.js'; import { ok, err, fmtJSON, meterRow, historyStore, copyToClipboard } from '../lib/ui.js';
const sweepHist = historyStore('cx_inbox_sweeps', 15);
const routeHist = historyStore('cx_inbox_routes', 15);
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div> <div><div class="title">Inbox</div><div class="sub">Route artifacts through the CxAI inbox classifier.</div></div>
<div class="title">Inbox</div>
<div class="sub">Route artifacts through the CxAI inbox classifier.</div>
</div>
<div class="grow"></div> <div class="grow"></div>
<button class="btn btn-secondary" id="inbox-refresh">Refresh</button> <button class="btn btn-secondary" id="inbox-refresh">Sweep now</button>
</div> </div>
<div class="grid cols-2"> <div class="grid cols-3 mb-3" id="inbox-kpis"></div>
<div class="grid cols-2 mb-3">
<div class="card"> <div class="card">
<div class="card-title"><h2>Sweep</h2><span class="muted mono" id="inbox-sweep-meta">idle</span></div> <div class="card-title"><h2>Sweep</h2><span class="muted mono xsmall" id="inbox-sweep-meta">idle</span></div>
<form id="inbox-sweep-form" class="grid cols-2 gap-3"> <form id="inbox-sweep-form" class="grid cols-2 gap-3">
<label class="field"> <label class="field">
<span class="lbl">Mode</span> <span class="lbl">Mode</span>
@ -26,48 +28,121 @@ const TPL = `
</select> </select>
</label> </label>
<label class="field"> <label class="field">
<span class="lbl">Threshold</span> <span class="lbl">Limit</span>
<input class="input" id="inbox-threshold" type="number" min="0" max="1" step="0.05" value="0.7" /> <input class="input" id="inbox-limit" type="number" min="0" value="0" placeholder="0 = no limit"/>
</label> </label>
<label class="check" style="grid-column: span 2"><input type="checkbox" id="inbox-dry" /> dry-run (don't move files)</label> <div style="grid-column: span 2">
<button class="btn btn-primary" type="submit" style="grid-column: span 2">Run sweep</button> <label class="lbl">Confidence threshold <span class="mono" id="inbox-thr-val">0.70</span></label>
<input type="range" class="slider" id="inbox-threshold" min="0" max="1" step="0.05" value="0.7" style="width:100%"/>
<div id="inbox-thr-meter"></div>
</div>
<label class="check"><input type="checkbox" id="inbox-dry" checked/> dry-run</label>
<label class="check"><input type="checkbox" id="inbox-verbose"/> verbose</label>
<div class="btn-row" style="grid-column: span 2">
<button class="btn btn-primary" type="submit">Run sweep</button>
<button type="button" class="btn btn-secondary" data-preset="strict">Strict (0.9)</button>
<button type="button" class="btn btn-secondary" data-preset="normal">Normal (0.7)</button>
<button type="button" class="btn btn-secondary" data-preset="loose">Loose (0.4)</button>
</div>
</form> </form>
<div class="mt-3">
<h3 class="mb-2">Last result</h3>
<pre id="inbox-result" class="muted">no sweep yet</pre>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Route a single file</h2><span class="muted" id="inbox-route-meta"></span></div> <div class="card-title"><h2>Route a single file</h2><span class="muted xsmall" id="inbox-route-meta"></span></div>
<form id="inbox-route-form" class="flex gap-2 mb-3"> <form id="inbox-route-form" class="grid gap-2 mb-2">
<input class="input" id="inbox-route-path" placeholder="/absolute/path/to/file.md" /> <input class="input" id="inbox-route-path" placeholder="/absolute/path/to/file.md"/>
<label class="check"><input type="checkbox" id="inbox-route-dry" /> dry</label> <div class="btn-row">
<button class="btn btn-primary" type="submit">Route</button> <button class="btn btn-primary" type="submit">Route</button>
<label class="check"><input type="checkbox" id="inbox-route-dry" checked/> dry-run</label>
</div>
</form> </form>
<pre id="inbox-route-result" class="muted">no route yet</pre> <details class="card-d"><summary>Recent routes</summary>
<div id="inbox-route-history" class="body"></div>
</details>
</div>
</div>
<div class="grid cols-2">
<div class="card">
<div class="card-title"><h2>Last sweep</h2><button class="btn btn-ghost" id="inbox-copy">Copy</button></div>
<pre id="inbox-result" class="code muted" style="max-height:36vh">no sweep yet</pre>
</div>
<div class="card">
<div class="card-title"><h2>Route result</h2></div>
<pre id="inbox-route-result" class="code muted" style="max-height:36vh">no route yet</pre>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Sweep history</h2></div>
<div id="inbox-sweep-history"></div>
</div> </div>
</div> </div>
`; `;
function kpi(label, value, sub) {
return `<div class="kpi"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(String(value))}</div><div class="sub">${escapeHtml(sub || '')}</div></div>`;
}
function updateThr(host) {
const v = +host.querySelector('#inbox-threshold').value;
host.querySelector('#inbox-thr-val').textContent = v.toFixed(2);
const state = v >= 0.8 ? 'ok' : v >= 0.5 ? 'warn' : 'err';
host.querySelector('#inbox-thr-meter').innerHTML = meterRow('confidence', v * 100, `${(v * 100).toFixed(0)}%`, state);
}
function renderHist(host) {
const sw = sweepHist.list();
host.querySelector('#inbox-sweep-history').innerHTML = sw.length
? sw.map((r, i) => `<button class="nav-item" data-hi="${i}">
<span class="muted xsmall mono">${new Date(r._ts).toLocaleTimeString()}</span>
<span>${escapeHtml(r.mode)} · thr=${r.threshold}</span>
<span class="pill ${r.ok ? 'ok' : 'err'}">${r.ok ? `${r.routed ?? 0} routed` : 'error'}</span>
</button>`).join('')
: '<div class="muted xsmall p-2">no sweeps yet</div>';
host.querySelectorAll('#inbox-sweep-history [data-hi]').forEach(b => b.addEventListener('click', () => {
host.querySelector('#inbox-result').textContent = fmtJSON(sweepHist.list()[+b.dataset.hi]);
}));
const ro = routeHist.list();
host.querySelector('#inbox-route-history').innerHTML = ro.length
? ro.map((r, i) => `<button class="nav-item" data-rhi="${i}">
<span class="muted xsmall mono">${new Date(r._ts).toLocaleTimeString()}</span>
<span class="truncate">${escapeHtml(r.path)}</span>
<span class="pill ${r.ok ? 'ok' : 'err'}">${r.target || (r.ok ? 'ok' : 'err')}</span>
</button>`).join('')
: '<div class="muted xsmall p-2">no routes yet</div>';
host.querySelectorAll('#inbox-route-history [data-rhi]').forEach(b => b.addEventListener('click', () => {
const r = routeHist.list()[+b.dataset.rhi];
host.querySelector('#inbox-route-path').value = r.path;
host.querySelector('#inbox-route-result').textContent = fmtJSON(r);
}));
}
async function runSweep(host) { async function runSweep(host) {
const mode = host.querySelector('#inbox-mode').value; const mode = host.querySelector('#inbox-mode').value;
const threshold = parseFloat(host.querySelector('#inbox-threshold').value); const threshold = parseFloat(host.querySelector('#inbox-threshold').value);
const limit = +host.querySelector('#inbox-limit').value || 0;
const dry_run = host.querySelector('#inbox-dry').checked; const dry_run = host.querySelector('#inbox-dry').checked;
const verbose = host.querySelector('#inbox-verbose').checked;
host.querySelector('#inbox-sweep-meta').textContent = 'running…'; host.querySelector('#inbox-sweep-meta').textContent = 'running…';
host.querySelector('#inbox-result').textContent = 'running…'; host.querySelector('#inbox-result').textContent = 'running…';
const args = { mode, threshold, dry_run };
if (limit > 0) args.limit = limit;
if (verbose) args.verbose = true;
try { try {
const r = await mcp.call('inbox_sweep_tool', { mode, threshold, dry_run }); const r = await mcp.call('inbox_sweep_tool', args);
host.querySelector('#inbox-result').textContent = fmtJSON(r); host.querySelector('#inbox-result').textContent = fmtJSON(r);
const total = (r?.routed?.length ?? r?.routed ?? 0) || (r?.summary?.routed ?? 0); const total = (Array.isArray(r?.routed) ? r.routed.length : r?.routed) ?? r?.summary?.routed ?? 0;
host.querySelector('#inbox-sweep-meta').textContent = host.querySelector('#inbox-sweep-meta').textContent = dry_run ? `dry-run · ${total} candidates` : `routed ${total}`;
dry_run ? `dry-run · ${JSON.stringify(r).length}b` : `routed ${total}`;
const pill = document.getElementById('nav-inbox-pill'); const pill = document.getElementById('nav-inbox-pill');
if (pill && !dry_run) pill.textContent = String(total || ''); if (pill && !dry_run) pill.textContent = String(total || '');
sweepHist.push({ mode, threshold, dry_run, routed: total, ok: true, response: r });
renderHist(host); updateKpis(host, r);
ok(`Sweep complete${dry_run ? ' (dry)' : ''}`); ok(`Sweep complete${dry_run ? ' (dry)' : ''}`);
} catch (e) { } catch (e) {
host.querySelector('#inbox-result').textContent = fmtJSON(e.body ?? { error: e.message }); host.querySelector('#inbox-result').textContent = fmtJSON(e.body ?? { error: e.message });
host.querySelector('#inbox-sweep-meta').textContent = `error ${e.status ?? ''}`; host.querySelector('#inbox-sweep-meta').textContent = `error ${e.status ?? ''}`;
sweepHist.push({ mode, threshold, dry_run, ok: false, error: e.message });
renderHist(host);
err(`sweep: ${e.message}`); err(`sweep: ${e.message}`);
} }
} }
@ -81,21 +156,45 @@ async function routeOne(host) {
try { try {
const r = await mcp.call('route_file_tool', { path, dry_run }); const r = await mcp.call('route_file_tool', { path, dry_run });
host.querySelector('#inbox-route-result').textContent = fmtJSON(r); host.querySelector('#inbox-route-result').textContent = fmtJSON(r);
host.querySelector('#inbox-route-meta').textContent = r?.target || r?.destination || 'ok'; const target = r?.target || r?.destination || 'ok';
host.querySelector('#inbox-route-meta').textContent = target;
routeHist.push({ path, dry_run, target, ok: true });
renderHist(host);
ok(`Routed ${path.split('/').pop()}`); ok(`Routed ${path.split('/').pop()}`);
} catch (e) { } catch (e) {
host.querySelector('#inbox-route-result').textContent = fmtJSON(e.body ?? { error: e.message }); host.querySelector('#inbox-route-result').textContent = fmtJSON(e.body ?? { error: e.message });
host.querySelector('#inbox-route-meta').textContent = `error ${e.status ?? ''}`; host.querySelector('#inbox-route-meta').textContent = `error ${e.status ?? ''}`;
routeHist.push({ path, dry_run, ok: false, error: e.message });
renderHist(host);
err(`route: ${e.message}`); err(`route: ${e.message}`);
} }
} }
function updateKpis(host, r) {
const routed = (Array.isArray(r?.routed) ? r.routed.length : r?.routed) ?? r?.summary?.routed ?? 0;
const skipped = (Array.isArray(r?.skipped) ? r.skipped.length : r?.skipped) ?? r?.summary?.skipped ?? 0;
const queued = r?.summary?.queued ?? r?.queued ?? '—';
host.querySelector('#inbox-kpis').innerHTML = [
kpi('Routed (last)', routed, 'files moved'),
kpi('Skipped (last)', skipped, 'low-confidence'),
kpi('Queue', queued, 'remaining'),
].join('');
}
registerPane('inbox', { registerPane('inbox', {
label: 'Inbox', label: 'Inbox',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
updateThr(host); updateKpis(host, {});
host.querySelector('#inbox-threshold').addEventListener('input', () => updateThr(host));
host.querySelectorAll('[data-preset]').forEach(b => b.addEventListener('click', () => {
const v = { strict: 0.9, normal: 0.7, loose: 0.4 }[b.dataset.preset];
host.querySelector('#inbox-threshold').value = v; updateThr(host);
}));
host.querySelector('#inbox-sweep-form').addEventListener('submit', (e) => { e.preventDefault(); runSweep(host); }); host.querySelector('#inbox-sweep-form').addEventListener('submit', (e) => { e.preventDefault(); runSweep(host); });
host.querySelector('#inbox-route-form').addEventListener('submit', (e) => { e.preventDefault(); routeOne(host); }); host.querySelector('#inbox-route-form').addEventListener('submit', (e) => { e.preventDefault(); routeOne(host); });
host.querySelector('#inbox-refresh').addEventListener('click', () => runSweep(host)); host.querySelector('#inbox-refresh').addEventListener('click', () => runSweep(host));
host.querySelector('#inbox-copy').addEventListener('click', () => copyToClipboard(host.querySelector('#inbox-result').textContent));
renderHist(host);
}, },
}); });

View File

@ -1,54 +1,105 @@
// panes/items.js — CRUD against /api/items. // panes/items.js — CRUD against /api/items with search, bulk select, export/import.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, jpost, jdelete, escapeHtml } from '../lib/api.js'; import { jget, jpost, jdelete, escapeHtml } from '../lib/api.js';
import { ok, err } from '../lib/ui.js'; import { ok, err, copyToClipboard, fmtJSON } from '../lib/ui.js';
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">Items</div><div class="sub">Backend-managed records (/api/items).</div></div> <div><div class="title">Items</div><div class="sub">Backend-managed records · <code>/api/items</code>.</div></div>
<div class="grow"></div> <div class="grow"></div>
<button class="btn btn-secondary" id="items-export">Export</button>
<button class="btn btn-secondary" id="items-import">Import</button>
<button class="btn btn-secondary" id="items-refresh">Refresh</button> <button class="btn btn-secondary" id="items-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2"> <div class="grid" style="grid-template-columns: 360px 1fr; gap: 16px;">
<div class="card"> <div class="card">
<div class="card-title"><h2>New item</h2></div> <div class="card-title"><h2>New item</h2></div>
<form id="items-form" class="grid gap-3"> <form id="items-form" class="grid gap-3">
<label class="field"><span class="lbl">Name</span><input class="input" id="items-name" required /></label> <label class="field"><span class="lbl">Name</span><input class="input" id="items-name" required maxlength="120"/></label>
<label class="field"><span class="lbl">Description</span><textarea class="input" id="items-desc" rows="3"></textarea></label> <label class="field"><span class="lbl">Description</span><textarea class="input" id="items-desc" rows="4" maxlength="2000"></textarea></label>
<button class="btn btn-primary" type="submit">Add item</button> <div class="btn-row">
<button class="btn btn-primary" type="submit">Add</button>
<button class="btn btn-ghost" type="reset">Clear</button>
</div>
</form> </form>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Bulk actions</h2><span class="muted xsmall" id="items-sel-count">0 selected</span></div>
<div class="btn-row">
<button class="btn btn-secondary" id="items-sel-all">Select all</button>
<button class="btn btn-secondary" id="items-sel-none">Clear</button>
<button class="btn btn-danger" id="items-sel-del" disabled>Delete selected</button>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>List</h2><span class="muted mono" id="items-count"></span></div> <div class="card-title">
<div id="items-list">loading</div> <h2>Records <span class="muted mono xsmall" id="items-count"></span></h2>
<input class="input" id="items-search" placeholder="filter…" style="max-width:240px"/>
</div>
<div id="items-list" class="scroll" style="max-height:62vh">loading</div>
</div> </div>
</div> </div>
<input type="file" id="items-file" accept=".json" style="display:none"/>
`; `;
let all = [];
const selected = new Set();
function row(it, checked) {
return `<div class="row" data-id="${it.id}">
<label class="check"><input type="checkbox" data-sel="${it.id}" ${checked ? 'checked' : ''}/></label>
<span class="mono muted">#${it.id}</span>
<div class="grow">
<div class="ttl"><span data-edit="name-${it.id}">${escapeHtml(it.name)}</span></div>
<div class="desc"><span data-edit="desc-${it.id}">${escapeHtml(it.description || '')}</span></div>
</div>
<button class="btn btn-ghost" data-copy="${it.id}" title="Copy as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
<span class="act" data-del="${it.id}">delete</span>
</div>`;
}
function render(host) {
const q = host.querySelector('#items-search').value.trim().toLowerCase();
const filtered = q ? all.filter(it => (it.name + ' ' + (it.description || '')).toLowerCase().includes(q)) : all;
host.querySelector('#items-count').textContent = `${filtered.length}/${all.length}`;
host.querySelector('#items-list').innerHTML = filtered.length
? filtered.map(it => row(it, selected.has(it.id))).join('')
: '<div class="muted" style="padding:16px">no items match</div>';
host.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
if (!confirm(`Delete item #${b.dataset.del}?`)) return;
try { await jdelete('/api/items/' + b.dataset.del); selected.delete(+b.dataset.del); await load(host); ok('deleted'); }
catch (e) { err(e.message); }
}));
host.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => {
const it = all.find(x => x.id === +b.dataset.copy); if (it) copyToClipboard(fmtJSON(it), 'copied');
}));
host.querySelectorAll('[data-sel]').forEach(c => c.addEventListener('change', () => {
const id = +c.dataset.sel;
if (c.checked) selected.add(id); else selected.delete(id);
updateSel(host);
}));
}
function updateSel(host) {
host.querySelector('#items-sel-count').textContent = `${selected.size} selected`;
host.querySelector('#items-sel-del').disabled = selected.size === 0;
}
async function load(host) { async function load(host) {
try { try {
const j = await jget('/api/items'); const j = await jget('/api/items');
const items = j.items || []; all = j.items || [];
host.querySelector('#items-count').textContent = String(j.count ?? items.length); // prune stale selections
host.querySelector('#items-list').innerHTML = items.length ? items.map(it => ` const ids = new Set(all.map(x => x.id));
<div class="row" data-id="${it.id}"> for (const id of [...selected]) if (!ids.has(id)) selected.delete(id);
<span class="mono muted">#${it.id}</span> render(host); updateSel(host);
<div class="grow">
<div class="ttl">${escapeHtml(it.name)}</div>
<div class="desc">${escapeHtml(it.description || '')}</div>
</div>
<span class="act" data-del="${it.id}">delete</span>
</div>
`).join('') : '<div class="muted" style="padding:12px">no items</div>';
host.querySelectorAll('[data-del]').forEach(b => {
b.addEventListener('click', async () => {
try { await jdelete('/api/items/' + b.dataset.del); ok('deleted'); load(host); }
catch (e) { err(e.message); }
});
});
} catch (e) { } catch (e) {
host.querySelector('#items-list').innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`; host.querySelector('#items-list').innerHTML = `<div class="muted" style="padding:16px">${escapeHtml(e.message)}</div>`;
} }
} }
@ -57,6 +108,7 @@ registerPane('items', {
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#items-refresh').addEventListener('click', () => load(host)); host.querySelector('#items-refresh').addEventListener('click', () => load(host));
host.querySelector('#items-search').addEventListener('input', () => render(host));
host.querySelector('#items-form').addEventListener('submit', async (e) => { host.querySelector('#items-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const name = host.querySelector('#items-name').value.trim(); const name = host.querySelector('#items-name').value.trim();
@ -64,12 +116,46 @@ registerPane('items', {
if (!name) return; if (!name) return;
try { try {
await jpost('/api/items', { name, description }); await jpost('/api/items', { name, description });
host.querySelector('#items-name').value = ''; host.querySelector('#items-form').reset();
host.querySelector('#items-desc').value = ''; ok('added'); await load(host);
ok('added');
load(host);
} catch (e) { err(e.message); } } catch (e) { err(e.message); }
}); });
host.querySelector('#items-sel-all').addEventListener('click', () => { all.forEach(x => selected.add(x.id)); render(host); updateSel(host); });
host.querySelector('#items-sel-none').addEventListener('click', () => { selected.clear(); render(host); updateSel(host); });
host.querySelector('#items-sel-del').addEventListener('click', async () => {
const ids = [...selected];
if (!ids.length) return;
if (!confirm(`Delete ${ids.length} item(s)?`)) return;
for (const id of ids) { try { await jdelete('/api/items/' + id); } catch (e) { err(`#${id}: ${e.message}`); } }
selected.clear(); ok(`deleted ${ids.length}`); await load(host);
});
host.querySelector('#items-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(all, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `cxwebapp-items-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
host.querySelector('#items-import').addEventListener('click', () => host.querySelector('#items-file').click());
host.querySelector('#items-file').addEventListener('change', async (e) => {
const f = e.target.files?.[0]; if (!f) return;
try {
const text = await f.text();
const arr = JSON.parse(text);
if (!Array.isArray(arr)) throw new Error('expected JSON array');
let n = 0;
for (const it of arr) {
if (!it?.name) continue;
try { await jpost('/api/items', { name: it.name, description: it.description || '' }); n++; } catch {}
}
ok(`imported ${n}/${arr.length}`); await load(host);
} catch (err2) { err('import: ' + err2.message); }
e.target.value = '';
});
load(host); load(host);
}, },
refresh: load, refresh: load,

View File

@ -1,48 +1,124 @@
// panes/lang.js — language pipelines (LangChain-ish sidecar). // panes/lang.js — language pipelines with cards, examples, run history.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, jpost, escapeHtml } from '../lib/api.js'; import { jget, jpost, escapeHtml } from '../lib/api.js';
import { fmtJSON, err } from '../lib/ui.js'; import { fmtJSON, err, ok, copyToClipboard, historyStore } from '../lib/ui.js';
const runHist = historyStore('cx_lang_runs', 20);
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">Lang</div><div class="sub">Run language pipelines.</div></div> <div><div class="title">Lang</div><div class="sub">Run language pipelines via the sidecar.</div></div>
<div class="grow"></div> <div class="grow"></div>
<span class="muted" id="lang-status"></span> <span class="pill" id="lang-pill"><span class="dot"></span><span id="lang-pill-text"></span></span>
<button class="btn btn-secondary" id="lang-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2">
<div class="card"> <div class="grid" style="grid-template-columns: 320px 1fr; gap: 16px;">
<div class="card-title"><h2>Pipelines</h2></div> <div class="card no-pad scroll" style="max-height:74vh">
<div id="lang-pipelines" class="mb-3"></div> <div class="p-2"><input class="input" id="lang-search" placeholder="filter pipelines…"/></div>
<div class="grid gap-3"> <div id="lang-pipelines">loading</div>
<label class="field"><span class="lbl">Pipeline</span><select class="input" id="lang-pipeline"></select></label>
<label class="field"><span class="lbl">Input (JSON)</span><textarea class="input" id="lang-input" rows="6">{}</textarea></label>
<div class="btn-row">
<button class="btn btn-primary" id="lang-run">Run</button>
<button class="btn btn-secondary" id="lang-health">Health</button>
</div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Result</h2></div> <div class="card-title">
<pre id="lang-result" class="muted"></pre> <h2 id="lang-name">Select a pipeline</h2>
<span class="muted xsmall" id="lang-status"></span>
</div>
<p class="muted small" id="lang-desc">Pick a pipeline on the left.</p>
<div id="lang-body" hidden>
<div class="card-title"><h2 style="font-size:14px">Examples</h2></div>
<div class="chips mb-3" id="lang-examples"></div>
<label class="field"><span class="lbl">Input (JSON)</span>
<textarea class="input mono" id="lang-input" rows="8">{}</textarea>
</label>
<div class="btn-row mt-3">
<button class="btn btn-primary" id="lang-run">Run</button>
<button class="btn btn-secondary" id="lang-format">Format JSON</button>
<button class="btn btn-secondary" id="lang-copy-input">Copy input</button>
<button class="btn btn-ghost" id="lang-health">Health</button>
</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Result</h2>
<div class="flex gap-2">
<span class="muted xsmall" id="lang-meta"></span>
<button class="btn btn-ghost" id="lang-copy-out">Copy</button>
</div>
</div>
<pre id="lang-result" class="code muted" style="max-height:32vh"></pre>
<details class="card-d mt-3"><summary>Recent runs</summary>
<div id="lang-history" class="body"></div>
</details>
</div>
</div> </div>
</div> </div>
`; `;
let pipelines = [];
let current = null;
function renderList(host) {
const q = host.querySelector('#lang-search').value.trim().toLowerCase();
const items = pipelines.filter(p => !q || (p.name + ' ' + (p.description || '')).toLowerCase().includes(q));
host.querySelector('#lang-pipelines').innerHTML = items.length
? items.map(p => `<button class="nav-item" data-p="${escapeHtml(p.name)}">
<span class="grow">
<div class="mono">${escapeHtml(p.name)}</div>
<div class="muted xsmall truncate">${escapeHtml(p.description || '')}</div>
</span>
</button>`).join('')
: '<div class="muted xsmall p-2">no pipelines</div>';
host.querySelectorAll('[data-p]').forEach(b => b.addEventListener('click', () => select(host, b.dataset.p)));
}
function renderHistory(host) {
const list = runHist.list().filter(r => !current || r.name === current.name);
host.querySelector('#lang-history').innerHTML = list.length
? list.map((r, i) => `<button class="nav-item" data-hi="${i}">
<span class="muted xsmall mono">${new Date(r._ts).toLocaleTimeString()}</span>
<span class="grow truncate">${escapeHtml(r.name)}</span>
<span class="pill ${r.ok ? 'ok' : 'err'}">${r.ok ? `${r.ms || '?'}ms` : 'err'}</span>
</button>`).join('')
: '<div class="muted xsmall p-2">no runs</div>';
host.querySelectorAll('#lang-history [data-hi]').forEach(b => b.addEventListener('click', () => {
const r = list[+b.dataset.hi];
if (r) { host.querySelector('#lang-input').value = fmtJSON(r.input); host.querySelector('#lang-result').textContent = fmtJSON(r.output); }
}));
}
function select(host, name) {
current = pipelines.find(p => p.name === name);
if (!current) return;
host.querySelector('#lang-name').textContent = current.name;
host.querySelector('#lang-desc').textContent = current.description || '—';
host.querySelector('#lang-body').hidden = false;
const ex = current.examples || current.sample_inputs || [];
host.querySelector('#lang-examples').innerHTML = ex.length
? ex.map((e, i) => `<button class="chip" data-ex="${i}">${escapeHtml(e.label || e.name || `example ${i + 1}`)}</button>`).join('')
: '<span class="muted xsmall">no examples</span>';
host.querySelectorAll('#lang-examples .chip').forEach(b => b.addEventListener('click', () => {
const e = ex[+b.dataset.ex];
host.querySelector('#lang-input').value = fmtJSON(e.input ?? e.value ?? e);
}));
host.querySelector('#lang-input').value = fmtJSON(current.default_input || {});
renderHistory(host);
}
async function refresh(host) { async function refresh(host) {
try { try {
const r = await jget('/api/lang/pipelines'); const r = await jget('/api/lang/pipelines');
const items = r.pipelines || []; pipelines = r.pipelines || [];
host.querySelector('#lang-pipelines').innerHTML = items.map(p => renderList(host);
`<div class="row"><div class="grow"><div class="ttl mono">${escapeHtml(p.name)}</div><div class="desc">${escapeHtml(p.description || '')}</div></div></div>` host.querySelector('#lang-pill').classList.remove('err', 'warn'); host.querySelector('#lang-pill').classList.add('ok');
).join('') || '<div class="muted" style="padding:12px">no pipelines</div>'; host.querySelector('#lang-pill-text').textContent = `${pipelines.length} pipelines`;
const sel = host.querySelector('#lang-pipeline');
if (sel && sel.children.length === 0) {
sel.innerHTML = items.map(p => `<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)}</option>`).join('');
}
host.querySelector('#lang-status').textContent = 'ok';
} catch (e) { } catch (e) {
host.querySelector('#lang-status').textContent = 'upstream unavailable: ' + e.message; host.querySelector('#lang-pipelines').innerHTML = `<div class="muted xsmall p-2">${escapeHtml(e.message)}</div>`;
host.querySelector('#lang-pill').classList.add('err'); host.querySelector('#lang-pill-text').textContent = 'down';
} }
} }
@ -50,25 +126,42 @@ registerPane('lang', {
label: 'Lang', label: 'Lang',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#lang-refresh').addEventListener('click', () => refresh(host));
host.querySelector('#lang-search').addEventListener('input', () => renderList(host));
host.querySelector('#lang-run').addEventListener('click', async () => { host.querySelector('#lang-run').addEventListener('click', async () => {
const name = host.querySelector('#lang-pipeline').value; if (!current) return err('select a pipeline');
let input; let input;
try { input = JSON.parse(host.querySelector('#lang-input').value || '{}'); } try { input = JSON.parse(host.querySelector('#lang-input').value || '{}'); }
catch (e) { return err(`bad JSON: ${e.message}`); } catch (e) { return err(`bad JSON: ${e.message}`); }
host.querySelector('#lang-status').textContent = 'running…'; host.querySelector('#lang-status').textContent = 'running…';
const t0 = performance.now();
try { try {
const r = await jpost(`/api/lang/pipelines/${encodeURIComponent(name)}`, { input }); const r = await jpost(`/api/lang/pipelines/${encodeURIComponent(current.name)}`, { input });
const ms = +r.duration_ms || (performance.now() - t0).toFixed(0);
host.querySelector('#lang-result').textContent = fmtJSON(r); host.querySelector('#lang-result').textContent = fmtJSON(r);
host.querySelector('#lang-status').textContent = `ok · ${r.duration_ms ?? '?'}ms`; host.querySelector('#lang-status').textContent = `ok · ${ms}ms`;
host.querySelector('#lang-meta').textContent = `${ms}ms`;
runHist.push({ name: current.name, input, output: r, ms, ok: true });
renderHistory(host); ok('done');
} catch (e) { } catch (e) {
host.querySelector('#lang-result').textContent = fmtJSON(e.body ?? { error: e.message }); host.querySelector('#lang-result').textContent = fmtJSON(e.body ?? { error: e.message });
host.querySelector('#lang-status').textContent = `error ${e.status ?? ''}`; host.querySelector('#lang-status').textContent = `error ${e.status ?? ''}`;
runHist.push({ name: current.name, input, error: e.message, ok: false });
renderHistory(host); err(e.message);
} }
}); });
host.querySelector('#lang-format').addEventListener('click', () => {
const ta = host.querySelector('#lang-input');
try { ta.value = fmtJSON(JSON.parse(ta.value || '{}')); } catch (e) { err('bad JSON: ' + e.message); }
});
host.querySelector('#lang-copy-input').addEventListener('click', () => copyToClipboard(host.querySelector('#lang-input').value));
host.querySelector('#lang-copy-out').addEventListener('click', () => copyToClipboard(host.querySelector('#lang-result').textContent));
host.querySelector('#lang-health').addEventListener('click', async () => { host.querySelector('#lang-health').addEventListener('click', async () => {
try { host.querySelector('#lang-result').textContent = fmtJSON(await jget('/api/lang/healthz')); } try { host.querySelector('#lang-result').textContent = fmtJSON(await jget('/api/lang/healthz')); }
catch (e) { host.querySelector('#lang-result').textContent = e.message; } catch (e) { host.querySelector('#lang-result').textContent = e.message; }
}); });
refresh(host); refresh(host);
}, },
refresh, refresh,

View File

@ -1,34 +1,82 @@
// panes/mac.js — macOS app distribution. // panes/mac.js — macOS native app distribution.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget } from '../lib/api.js'; import { jget, escapeHtml, formatUptime } from '../lib/api.js';
import { fmtJSON } from '../lib/ui.js'; import { fmtJSON, fmtBytes, copyToClipboard, ok, err } from '../lib/ui.js';
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">macOS</div><div class="sub">Native app distribution.</div></div> <div><div class="title">macOS</div><div class="sub">Native CxLLM Desktop client distribution.</div></div>
<div class="grow"></div> <div class="grow"></div>
<button class="btn btn-secondary" id="mac-refresh">Refresh</button> <button class="btn btn-secondary" id="mac-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2 mb-3">
<div class="card">
<div class="card-title"><h2>Latest build</h2>
<a class="btn btn-primary" id="mac-download" hidden>Download</a>
</div>
<dl class="dl" id="mac-build"></dl>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Checksum</h2>
<button class="btn btn-ghost" id="mac-copy-sha">Copy SHA-256</button>
</div>
<pre class="code" id="mac-sha" style="word-break:break-all"></pre>
</div>
<div class="card">
<div class="card-title"><h2>Install instructions</h2></div>
<ol class="muted small" style="line-height:1.9">
<li>Click <b>Download</b> to grab the latest <code>.app.zip</code> bundle.</li>
<li>Unzip and drag <code>CxLLM.app</code> into <code>/Applications</code>.</li>
<li>First launch may show a Gatekeeper warning. To allow:
<pre class="code">xattr -dr com.apple.quarantine /Applications/CxLLM.app</pre>
</li>
<li>Or right-click the app and choose <b>Open</b>, then confirm.</li>
</ol>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">System requirements</h2></div>
<ul class="muted small">
<li>macOS 13 Ventura or later</li>
<li>Apple silicon (arm64) or Intel (x86_64)</li>
<li>~120 MB disk · 200 MB RAM at idle</li>
</ul>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Build</h2><a class="btn btn-primary" id="mac-download" style="display:none">Download</a></div> <div class="card-title"><h2>Raw /api/mac/info</h2><button class="btn btn-ghost" id="mac-copy-json">Copy</button></div>
<pre id="mac-info"></pre> <pre class="code" id="mac-info" style="max-height:36vh"></pre>
</div> </div>
`; `;
function row(k, v) { return `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v ?? '—')}</dd>`; }
async function load(host) { async function load(host) {
try { try {
const r = await jget('/api/mac/info'); const r = await jget('/api/mac/info');
host.querySelector('#mac-info').textContent = fmtJSON(r); host.querySelector('#mac-info').textContent = fmtJSON(r);
const b = r.build || {};
host.querySelector('#mac-build').innerHTML =
row('Name', b.name) +
row('Version', b.version) +
row('Built', b.build_time) +
row('Channel', b.channel || 'stable') +
row('Size', b.size_bytes ? fmtBytes(b.size_bytes) : (b.size || '—')) +
row('Arch', (b.architectures || []).join(', ') || b.arch || '—');
host.querySelector('#mac-sha').textContent = b.sha256 || b.checksum || '—';
const a = host.querySelector('#mac-download'); const a = host.querySelector('#mac-download');
if (r.download_url) { if (r.download_url) {
a.href = r.download_url; a.href = r.download_url;
a.textContent = 'Download ' + (r.build?.name || 'app'); a.textContent = `Download ${b.name || 'CxLLM'} ${b.version || ''}`.trim();
a.style.display = ''; a.hidden = false;
a.download = (r.download_url.split('/').pop() || 'cxllm.app.zip');
} else { } else {
a.style.display = 'none'; a.hidden = true;
} }
} catch (e) { } catch (e) {
host.querySelector('#mac-info').textContent = e.message; host.querySelector('#mac-info').textContent = e.message;
err(e.message);
} }
} }
@ -37,6 +85,8 @@ registerPane('mac', {
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#mac-refresh').addEventListener('click', () => load(host)); host.querySelector('#mac-refresh').addEventListener('click', () => load(host));
host.querySelector('#mac-copy-sha').addEventListener('click', () => copyToClipboard(host.querySelector('#mac-sha').textContent.trim(), 'sha copied'));
host.querySelector('#mac-copy-json').addEventListener('click', () => copyToClipboard(host.querySelector('#mac-info').textContent, 'copied'));
load(host); load(host);
}, },
refresh: load, refresh: load,

View File

@ -1,38 +1,70 @@
// panes/slack.js — Slack sidecar. // panes/slack.js — Slack sidecar with respond form, presets, tools.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, jpost, escapeHtml } from '../lib/api.js'; import { jget, jpost, escapeHtml } from '../lib/api.js';
import { fmtJSON, err } from '../lib/ui.js'; import { fmtJSON, err, ok, copyToClipboard } from '../lib/ui.js';
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">Slack</div><div class="sub">Sidecar health and ad-hoc responses.</div></div> <div><div class="title">Slack</div><div class="sub">Sidecar health, conversation memory, ad-hoc responses.</div></div>
<div class="grow"></div> <div class="grow"></div>
<span class="pill" id="slack-pill"><span class="dot"></span><span id="slack-pill-text"></span></span>
<button class="btn btn-secondary" id="slack-refresh">Refresh</button> <button class="btn btn-secondary" id="slack-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2 mb-3">
<div class="card">
<div class="card-title"><h2>Respond</h2><span class="muted xsmall" id="slack-resp-meta"></span></div>
<form id="slack-form" class="grid gap-3">
<label class="field">
<span class="lbl">Message</span>
<textarea class="input" id="slack-text" rows="3" required placeholder="Hello team!"></textarea>
</label>
<div class="grid cols-2 gap-3">
<label class="field"><span class="lbl">Channel</span><input class="input" id="slack-channel" placeholder="rest" value="rest"/></label>
<label class="field"><span class="lbl">Thread ts</span><input class="input" id="slack-thread" placeholder="rest" value="rest"/></label>
</div>
<div class="btn-row">
<button class="btn btn-primary" type="submit">Generate reply</button>
<button type="button" class="btn btn-secondary" data-preset="standup">Standup</button>
<button type="button" class="btn btn-secondary" data-preset="bug">Bug report</button>
<button type="button" class="btn btn-secondary" data-preset="ship">Ship update</button>
</div>
</form>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Reply</h2><button class="btn btn-ghost" id="slack-copy">Copy</button></div>
<pre id="slack-reply" class="code muted" style="max-height:24vh"></pre>
</div>
<div class="card">
<div class="card-title"><h2>Tools</h2><span class="muted xsmall" id="slack-tools-meta"></span></div>
<div id="slack-tools-list" class="scroll" style="max-height:22vh"></div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Conversation memory</h2>
<button class="btn btn-ghost" id="slack-mem-copy">Copy</button>
</div>
<pre id="slack-memory" class="code muted" style="max-height:28vh"></pre>
</div>
</div>
<div class="grid cols-2"> <div class="grid cols-2">
<div class="card"> <div class="card">
<div class="card-title"><h2>Respond</h2></div> <div class="card-title"><h2>healthz</h2></div>
<form id="slack-form" class="grid gap-3"> <pre id="slack-health" class="code" style="max-height:24vh"></pre>
<label class="field"><span class="lbl">Message</span><textarea class="input" id="slack-text" rows="3" required></textarea></label>
<div class="grid cols-2 gap-3">
<label class="field"><span class="lbl">Channel</span><input class="input" id="slack-channel" placeholder="rest"/></label>
<label class="field"><span class="lbl">Thread ts</span><input class="input" id="slack-thread" placeholder="rest"/></label>
</div>
<button class="btn btn-primary" type="submit">Generate reply</button>
</form>
<h3 class="mt-3 mb-2">Reply</h3>
<pre id="slack-reply" class="muted"></pre>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Diagnostics</h2></div> <div class="card-title"><h2>info</h2></div>
<h3 class="mb-2">healthz</h3><pre id="slack-health"></pre> <pre id="slack-info" class="code" style="max-height:24vh"></pre>
<h3 class="mt-3 mb-2">info</h3><pre id="slack-info"></pre>
<h3 class="mt-3 mb-2">memory</h3><pre id="slack-memory"></pre>
<h3 class="mt-3 mb-2">tools</h3><div id="slack-tools"></div>
</div> </div>
</div> </div>
`; `;
const PRESETS = {
standup: 'Daily standup: ✅ Yesterday — ___ · 🎯 Today — ___ · 🚧 Blockers — none',
bug: 'Bug report: **Title** — short summary\n\n**Steps to reproduce:**\n1. ___\n2. ___\n\n**Expected:** ___\n**Actual:** ___\n**Env:** ___',
ship: '🚀 Shipped: **<feature>** — short summary. Try it at <link>. Feedback welcome 🙏',
};
async function refresh(host) { async function refresh(host) {
try { try {
const [h, info, mem, tools] = await Promise.all([ const [h, info, mem, tools] = await Promise.all([
@ -42,11 +74,20 @@ async function refresh(host) {
jget('/api/slack/tools').catch(e => ({ error: e.message })), jget('/api/slack/tools').catch(e => ({ error: e.message })),
]); ]);
host.querySelector('#slack-health').textContent = fmtJSON(h); host.querySelector('#slack-health').textContent = fmtJSON(h);
host.querySelector('#slack-info').textContent = fmtJSON(info); host.querySelector('#slack-info').textContent = fmtJSON(info);
host.querySelector('#slack-memory').textContent = fmtJSON(mem); host.querySelector('#slack-memory').textContent = fmtJSON(mem);
host.querySelector('#slack-tools').innerHTML = (tools.tools || []).map(t => const items = tools.tools || [];
`<div class="row"><div class="grow"><div class="ttl mono">${escapeHtml(t.name)}</div><div class="desc">${escapeHtml(t.description||'')}</div></div></div>` host.querySelector('#slack-tools-meta').textContent = `${items.length} tools`;
).join('') || '<div class="muted" style="padding:12px">(none)</div>'; host.querySelector('#slack-tools-list').innerHTML = items.length
? items.map(t => `<div class="row">
<div class="grow"><div class="ttl mono">${escapeHtml(t.name)}</div><div class="desc">${escapeHtml(t.description || '')}</div></div>
</div>`).join('')
: '<div class="muted xsmall p-2">(none)</div>';
const ok2 = !h?.error;
const pill = host.querySelector('#slack-pill');
pill.classList.remove('ok', 'err'); pill.classList.add(ok2 ? 'ok' : 'err');
host.querySelector('#slack-pill-text').textContent = ok2 ? 'up' : 'down';
} catch (e) { } catch (e) {
host.querySelector('#slack-health').textContent = e.message; host.querySelector('#slack-health').textContent = e.message;
} }
@ -57,19 +98,30 @@ registerPane('slack', {
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#slack-refresh').addEventListener('click', () => refresh(host)); host.querySelector('#slack-refresh').addEventListener('click', () => refresh(host));
host.querySelectorAll('[data-preset]').forEach(b => b.addEventListener('click', () => {
host.querySelector('#slack-text').value = PRESETS[b.dataset.preset] || '';
}));
host.querySelector('#slack-copy').addEventListener('click', () => copyToClipboard(host.querySelector('#slack-reply').textContent));
host.querySelector('#slack-mem-copy').addEventListener('click', () => copyToClipboard(host.querySelector('#slack-memory').textContent));
host.querySelector('#slack-form').addEventListener('submit', async (e) => { host.querySelector('#slack-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const body = { const body = {
text: host.querySelector('#slack-text').value, text: host.querySelector('#slack-text').value,
channel: host.querySelector('#slack-channel').value || 'rest', channel: host.querySelector('#slack-channel').value || 'rest',
thread_ts: host.querySelector('#slack-thread').value || 'rest', thread_ts: host.querySelector('#slack-thread').value || 'rest',
}; };
host.querySelector('#slack-reply').textContent = 'thinking…'; host.querySelector('#slack-reply').textContent = 'thinking…';
host.querySelector('#slack-resp-meta').textContent = '…';
const t0 = performance.now();
try { try {
const r = await jpost('/api/slack/respond', body); const r = await jpost('/api/slack/respond', body);
host.querySelector('#slack-reply').textContent = r.reply || fmtJSON(r); host.querySelector('#slack-reply').textContent = r.reply || fmtJSON(r);
host.querySelector('#slack-resp-meta').textContent = `${(performance.now() - t0).toFixed(0)}ms`;
ok('replied');
} catch (e) { } catch (e) {
host.querySelector('#slack-reply').textContent = e.body?.detail || e.message; host.querySelector('#slack-reply').textContent = e.body?.detail || e.message;
host.querySelector('#slack-resp-meta').textContent = `error ${e.status ?? ''}`;
err(e.message); err(e.message);
} }
}); });

View File

@ -1,32 +1,112 @@
// panes/system.js — /api/version + /api/system. // panes/system.js — process & host introspection with live sparklines.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { jget, formatUptime } from '../lib/api.js'; import { jget, formatUptime, escapeHtml } from '../lib/api.js';
import { fmtJSON } from '../lib/ui.js'; import { fmtJSON, fmtBytes, meterRow, sparkline, copyToClipboard, ok } from '../lib/ui.js';
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">System</div><div class="sub">Process introspection.</div></div> <div><div class="title">System</div><div class="sub">Process, host and runtime introspection.</div></div>
<div class="grow"></div> <div class="grow"></div>
<label class="check"><input type="checkbox" id="sys-live" checked/> live (5s)</label>
<button class="btn btn-secondary" id="sys-refresh">Refresh</button> <button class="btn btn-secondary" id="sys-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2">
<div class="card"><div class="card-title"><h2>Version</h2></div><pre id="sys-v"></pre></div> <div class="grid cols-4 mb-3" id="sys-kpis"></div>
<div class="grid cols-2 mb-3">
<div class="card"> <div class="card">
<div class="card-title"><h2>System</h2><span class="muted mono" id="sys-up"></span></div> <div class="card-title"><h2>Resources</h2><span class="muted xsmall" id="sys-cpu-ts"></span></div>
<pre id="sys-s"></pre> <div id="sys-gauges"></div>
<div class="divider"></div>
<div class="muted xsmall mb-1">Resident memory (last 60 samples)</div>
<div id="sys-spark-rss"></div>
<div class="muted xsmall mt-2 mb-1">CPU load (1m)</div>
<div id="sys-spark-cpu"></div>
</div>
<div class="card">
<div class="card-title"><h2>Build &amp; identity</h2></div>
<dl class="dl" id="sys-version"></dl>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Configured services</h2></div>
<div id="sys-services"></div>
</div>
</div>
<div class="grid cols-2">
<div class="card">
<div class="card-title"><h2>Raw /api/system</h2><button class="btn btn-ghost" data-copy="sys-s">Copy</button></div>
<pre id="sys-s" class="code" style="max-height:36vh"></pre>
</div>
<div class="card">
<div class="card-title"><h2>Raw /api/version</h2><button class="btn btn-ghost" data-copy="sys-v">Copy</button></div>
<pre id="sys-v" class="code" style="max-height:36vh"></pre>
</div> </div>
</div> </div>
`; `;
const rssHistory = [];
const cpuHistory = [];
let timer = null;
function kpi(label, value, sub) {
return `<div class="kpi"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div><div class="sub">${escapeHtml(sub || '')}</div></div>`;
}
function row(k, v) { return `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v ?? '—')}</dd>`; }
function memState(pct) { return pct > 85 ? 'err' : pct > 60 ? 'warn' : 'ok'; }
async function load(host) { async function load(host) {
try { let v = {}, s = {}, svc = { services: [] };
const [v, s] = await Promise.all([jget('/api/version'), jget('/api/system')]); try { [v, s, svc] = await Promise.all([jget('/api/version'), jget('/api/system'), jget('/api/services').catch(() => ({ services: [] }))]); }
host.querySelector('#sys-v').textContent = fmtJSON(v); catch (e) { host.querySelector('#sys-s').textContent = 'error: ' + e.message; return; }
host.querySelector('#sys-s').textContent = fmtJSON(s);
if (s.uptime_seconds != null) host.querySelector('#sys-up').textContent = formatUptime(s.uptime_seconds); host.querySelector('#sys-v').textContent = fmtJSON(v);
} catch (e) { host.querySelector('#sys-s').textContent = fmtJSON(s);
host.querySelector('#sys-v').textContent = 'error: ' + e.message;
} // KPIs
host.querySelector('#sys-kpis').innerHTML = [
kpi('Uptime', formatUptime(s.uptime_seconds), `pid ${s.pid ?? '—'}`),
kpi('Memory', fmtBytes(s.rss_bytes), s.mem_used_pct != null ? `${s.mem_used_pct.toFixed(1)}% of host` : 'resident set'),
kpi('CPUs', String(s.cpu_count || '—'), `load ${(s.loadavg?.[0] ?? 0).toFixed(2)} / ${(s.loadavg?.[1] ?? 0).toFixed(2)} / ${(s.loadavg?.[2] ?? 0).toFixed(2)}`),
kpi('Host', s.hostname || '—', `${s.sysname || ''} ${s.release || ''}`.trim()),
].join('');
// sparkline history
rssHistory.push(+s.rss_bytes || 0); while (rssHistory.length > 60) rssHistory.shift();
cpuHistory.push(+s.loadavg?.[0] || 0); while (cpuHistory.length > 60) cpuHistory.shift();
host.querySelector('#sys-spark-rss').innerHTML = sparkline(rssHistory);
host.querySelector('#sys-spark-cpu').innerHTML = sparkline(cpuHistory);
// gauges
const cpus = s.cpu_count || 1;
const loadPct = Math.min(100, (+s.loadavg?.[0] || 0) / cpus * 100);
const memPct = +s.mem_used_pct || 0;
host.querySelector('#sys-gauges').innerHTML = [
meterRow('Memory', memPct, `${memPct.toFixed(1)}%`, memState(memPct)),
meterRow('Load 1m', loadPct, (+s.loadavg?.[0] || 0).toFixed(2), loadPct > 80 ? 'err' : loadPct > 50 ? 'warn' : 'ok'),
meterRow('User CPU', Math.min(100, (s.user_cpu_s || 0) / Math.max(1, s.uptime_seconds) * 100), `${(s.user_cpu_s || 0).toFixed(1)}s`),
meterRow('Sys CPU', Math.min(100, (s.system_cpu_s || 0) / Math.max(1, s.uptime_seconds) * 100), `${(s.system_cpu_s || 0).toFixed(1)}s`),
].join('');
host.querySelector('#sys-cpu-ts').textContent = new Date().toLocaleTimeString();
// version dl
host.querySelector('#sys-version').innerHTML =
row('Name', v.name) +
row('Version', v.version) +
row('Git SHA', v.git_sha) +
row('Build time', v.build_time) +
row('C++ standard', s.cxx_standard) +
row('Architecture', s.machine) +
row('Total memory', fmtBytes(s.mem_total));
// configured services
host.querySelector('#sys-services').innerHTML = (svc.services || []).map(x => `
<div class="row">
<span class="pill ${x.configured ? 'ok' : 'muted'}"><span class="dot"></span>${escapeHtml(x.service)}</span>
<div class="grow muted small">${escapeHtml(x.prefix)}</div>
<span class="muted mono small">${x.configured ? `${escapeHtml(x.upstream_scheme || '')}://${escapeHtml(x.upstream_host || '')}:${x.upstream_port || ''}` : 'not configured'}</span>
</div>
`).join('') || '<div class="muted xsmall p-2">no services exposed</div>';
} }
registerPane('system', { registerPane('system', {
@ -34,7 +114,15 @@ registerPane('system', {
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#sys-refresh').addEventListener('click', () => load(host)); host.querySelector('#sys-refresh').addEventListener('click', () => load(host));
host.querySelectorAll('[data-copy]').forEach(b =>
b.addEventListener('click', () => copyToClipboard(host.querySelector('#' + b.dataset.copy)?.textContent || ''))
);
load(host); load(host);
timer = setInterval(() => {
if (!host.classList.contains('active')) return;
if (!host.querySelector('#sys-live')?.checked) return;
load(host);
}, 5000);
}, },
refresh: load, refresh: load,
}); });

View File

@ -1,108 +1,159 @@
// panes/tools.js — live MCP tool registry browser + ad-hoc invoke. // panes/tools.js — MCP tool browser & invoker with schema-driven forms.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { mcp, escapeHtml } from '../lib/api.js'; import { mcp, escapeHtml } from '../lib/api.js';
import { ok, err, fmtJSON } from '../lib/ui.js'; import { fmtJSON, ok, err, copyToClipboard, inputFromSchema, collectSchemaForm, historyStore } from '../lib/ui.js';
const callHist = historyStore('cx_tool_calls', 30);
const FAV_KEY = 'cx_tool_favs';
const favs = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]'));
function persistFavs() { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])); }
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div> <div><div class="title">Tools</div><div class="sub">Discover and invoke MCP tools.</div></div>
<div class="title">Tools</div>
<div class="sub">Every MCP tool exposed by the CxAI agent.</div>
</div>
<div class="grow"></div> <div class="grow"></div>
<input class="input" id="tools-filter" placeholder="filter…" style="max-width:240px" /> <button class="btn btn-secondary" id="tl-refresh">Reload</button>
<button class="btn btn-secondary" id="tools-refresh">Refresh</button>
</div> </div>
<div class="grid cols-2"> <div class="grid" style="grid-template-columns: 320px 1fr; gap: 16px;">
<div class="card"> <div class="card no-pad scroll" style="max-height:72vh">
<div class="card-title"><h2>Registry</h2><span class="muted mono" id="tools-meta">loading</span></div> <div class="p-2 flex gap-2">
<div id="tools-list" class="scroll" style="max-height:60vh">loading</div> <input class="input" id="tl-search" placeholder="filter tools…"/>
</div>
<div class="p-2">
<div class="seg" id="tl-seg">
<button class="active" data-g="all">All</button>
<button data-g="fav">Favorites</button>
<button data-g="recent">Recent</button>
</div>
</div>
<div id="tl-list">loading</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title"><h2>Invoke</h2><span class="muted mono" id="tools-invoke-meta"></span></div> <div class="card-title">
<div class="mb-2"><span class="mono" id="tools-selected">(select a tool)</span></div> <h2 id="tl-name">Select a tool</h2>
<label class="field mb-2"> <button class="btn btn-ghost" id="tl-fav" hidden></button>
<span class="lbl">Arguments (JSON)</span> </div>
<textarea class="input" id="tools-args" rows="8">{}</textarea> <p class="muted small" id="tl-desc">Choose a tool on the left.</p>
</label>
<button class="btn btn-primary" id="tools-call" disabled>Call</button> <div id="tl-body" hidden>
<h3 class="mt-3 mb-2">Response</h3> <div class="seg mb-2" id="tl-mode">
<pre id="tools-out" class="muted">no calls yet</pre> <button class="active" data-m="form">Form</button>
<button data-m="json">JSON</button>
</div>
<form id="tl-form" class="grid gap-3"></form>
<textarea id="tl-json" class="input" rows="8" placeholder='{"key":"value"}' hidden></textarea>
<div class="btn-row mt-3">
<button class="btn btn-primary" id="tl-call">Call</button>
<button class="btn btn-secondary" id="tl-copy-args">Copy args</button>
<button class="btn btn-ghost" id="tl-reset">Reset</button>
</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Result</h2><span id="tl-meta" class="muted xsmall"></span></div>
<pre id="tl-result" class="code" style="max-height:32vh"></pre>
<details class="card-d mt-3"><summary>Recent calls</summary>
<div id="tl-hist" class="body"></div>
</details>
</div>
</div> </div>
</div> </div>
`; `;
let allTools = []; let allTools = [];
let selected = null; let current = null;
let mode = 'form';
let group = 'all';
function render(host) { function passes(t, q) {
const filter = host.querySelector('#tools-filter').value.toLowerCase(); const blob = (t.name + ' ' + (t.description || '')).toLowerCase();
const list = allTools.filter(t => if (q && !blob.includes(q)) return false;
!filter || (t.name + ' ' + (t.description || '')).toLowerCase().includes(filter) if (group === 'fav' && !favs.has(t.name)) return false;
); if (group === 'recent') {
host.querySelector('#tools-meta').textContent = `${list.length} / ${allTools.length}`; const recents = new Set(callHist.list().map(r => r.tool));
host.querySelector('#tools-list').innerHTML = list.map(t => ` if (!recents.has(t.name)) return false;
<div class="tool-card" data-name="${escapeHtml(t.name)}"> }
<h4>${escapeHtml(t.name)}</h4> return true;
<p>${escapeHtml(t.description || '(no description)')}</p> }
</div>
`).join('') || '<div class="muted">no tools</div>'; function renderList(host) {
host.querySelectorAll('.tool-card').forEach(el => { const q = host.querySelector('#tl-search').value.trim().toLowerCase();
el.addEventListener('click', () => select(host, el.dataset.name)); const items = allTools.filter(t => passes(t, q));
}); host.querySelector('#tl-list').innerHTML = items.length
? items.map(t => `<button class="nav-item" data-tool="${escapeHtml(t.name)}">
<span class="grow truncate"><span class="mono">${escapeHtml(t.name)}</span></span>
${favs.has(t.name) ? '<span class="muted">★</span>' : ''}
</button>`).join('')
: '<div class="muted xsmall p-2">no tools match</div>';
host.querySelectorAll('[data-tool]').forEach(b => b.addEventListener('click', () => select(host, b.dataset.tool)));
}
function renderForm(host) {
if (!current) return;
const f = host.querySelector('#tl-form');
const schema = current.inputSchema || current.input_schema || {};
const props = schema.properties || {};
const names = Object.keys(props);
if (!names.length) {
f.innerHTML = '<div class="muted xsmall">no input schema — call with empty args or use JSON</div>';
return;
}
f.innerHTML = names.map(n => inputFromSchema(n, props[n])).join('');
}
function renderHist(host) {
const list = callHist.list().filter(r => r.tool === current?.name);
host.querySelector('#tl-hist').innerHTML = list.length
? list.map((r, i) => `<button class="nav-item" data-hi="${i}">
<span class="muted xsmall">${new Date(r.t).toLocaleTimeString()}</span>
<span class="grow truncate mono xsmall">${escapeHtml(JSON.stringify(r.args))}</span>
</button>`).join('')
: '<div class="muted xsmall">no recent calls</div>';
host.querySelectorAll('#tl-hist [data-hi]').forEach(b => b.addEventListener('click', () => {
const r = list[+b.dataset.hi];
if (r) { host.querySelector('#tl-json').value = fmtJSON(r.args); switchMode(host, 'json'); }
}));
} }
function select(host, name) { function select(host, name) {
selected = allTools.find(t => t.name === name) || null; current = allTools.find(t => t.name === name);
host.querySelector('#tools-selected').textContent = name; if (!current) return;
host.querySelector('#tools-call').disabled = !selected; host.querySelector('#tl-name').textContent = current.name;
// try to seed with example args from schema host.querySelector('#tl-desc').textContent = current.description || '—';
const schema = selected?.inputSchema || selected?.input_schema || selected?.schema; host.querySelector('#tl-body').hidden = false;
if (schema?.properties) { const favBtn = host.querySelector('#tl-fav');
const example = {}; favBtn.hidden = false;
for (const [k, v] of Object.entries(schema.properties)) { favBtn.textContent = favs.has(name) ? '★ unfavorite' : '☆ favorite';
if (v.default !== undefined) example[k] = v.default; host.querySelector('#tl-json').value = '{}';
else if (v.type === 'string') example[k] = ''; renderForm(host); renderHist(host);
else if (v.type === 'number' || v.type === 'integer') example[k] = 0;
else if (v.type === 'boolean') example[k] = false;
}
host.querySelector('#tools-args').value = JSON.stringify(example, null, 2);
} else {
host.querySelector('#tools-args').value = '{}';
}
host.querySelector('#tools-invoke-meta').textContent = selected ? 'ready' : '';
} }
async function call(host) { function switchMode(host, m) {
if (!selected) return; mode = m;
let args; host.querySelectorAll('#tl-mode button').forEach(b => b.classList.toggle('active', b.dataset.m === m));
try { args = JSON.parse(host.querySelector('#tools-args').value || '{}'); } host.querySelector('#tl-form').hidden = m !== 'form';
catch (e) { err(`Invalid JSON: ${e.message}`); return; } host.querySelector('#tl-json').hidden = m !== 'json';
host.querySelector('#tools-invoke-meta').textContent = 'calling…';
host.querySelector('#tools-out').textContent = 'calling…';
const t0 = performance.now();
try {
const r = await mcp.call(selected.name, args);
host.querySelector('#tools-out').textContent = fmtJSON(r);
host.querySelector('#tools-invoke-meta').textContent = `ok · ${Math.round(performance.now() - t0)}ms`;
ok(`${selected.name} ok`);
} catch (e) {
host.querySelector('#tools-out').textContent = fmtJSON(e.body ?? { error: e.message });
host.querySelector('#tools-invoke-meta').textContent = `error ${e.status ?? ''}`;
err(`${selected.name}: ${e.message}`);
}
} }
async function refresh(host) { function gatherArgs(host) {
host.querySelector('#tools-list').textContent = 'loading…'; if (mode === 'form') return collectSchemaForm(host.querySelector('#tl-form'));
const t = host.querySelector('#tl-json').value.trim();
if (!t) return {};
return JSON.parse(t);
}
async function load(host) {
try { try {
const r = await mcp.tools(); const j = await mcp.tools();
allTools = Array.isArray(r) ? r : (r.tools || r.result || []); allTools = (j.tools || []).slice().sort((a, b) => a.name.localeCompare(b.name));
render(host); renderList(host);
} catch (e) { } catch (e) {
host.querySelector('#tools-list').innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`; host.querySelector('#tl-list').innerHTML = `<div class="muted xsmall p-2">${escapeHtml(e.message)}</div>`;
host.querySelector('#tools-meta').textContent = 'error';
} }
} }
@ -110,10 +161,49 @@ registerPane('tools', {
label: 'Tools', label: 'Tools',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
host.querySelector('#tools-refresh').addEventListener('click', () => refresh(host)); host.querySelector('#tl-refresh').addEventListener('click', () => load(host));
host.querySelector('#tools-filter').addEventListener('input', () => render(host)); host.querySelector('#tl-search').addEventListener('input', () => renderList(host));
host.querySelector('#tools-call').addEventListener('click', () => call(host)); host.querySelectorAll('#tl-seg button').forEach(b => b.addEventListener('click', () => {
refresh(host); host.querySelectorAll('#tl-seg button').forEach(x => x.classList.toggle('active', x === b));
group = b.dataset.g; renderList(host);
}));
host.querySelectorAll('#tl-mode button').forEach(b => b.addEventListener('click', () => switchMode(host, b.dataset.m)));
host.querySelector('#tl-fav').addEventListener('click', () => {
if (!current) return;
if (favs.has(current.name)) favs.delete(current.name); else favs.add(current.name);
persistFavs();
host.querySelector('#tl-fav').textContent = favs.has(current.name) ? '★ unfavorite' : '☆ favorite';
renderList(host);
});
host.querySelector('#tl-copy-args').addEventListener('click', () => {
try { copyToClipboard(fmtJSON(gatherArgs(host)), 'args copied'); } catch (e) { err(e.message); }
});
host.querySelector('#tl-reset').addEventListener('click', () => {
if (current) select(host, current.name);
});
host.querySelector('#tl-call').addEventListener('click', async () => {
if (!current) return;
let args; try { args = gatherArgs(host); } catch (e) { return err('args: ' + e.message); }
const t0 = performance.now();
const pre = host.querySelector('#tl-result');
pre.textContent = 'calling…';
try {
const j = await mcp.call(current.name, args);
const dt = (performance.now() - t0).toFixed(0);
host.querySelector('#tl-meta').textContent = `${dt} ms`;
pre.textContent = fmtJSON(j);
callHist.push({ t: Date.now(), tool: current.name, args });
renderHist(host);
ok('called ' + current.name);
} catch (e) {
pre.textContent = 'error: ' + e.message;
err(e.message);
}
});
load(host);
}, },
refresh,
}); });

View File

@ -1,66 +1,181 @@
// panes/websocket.js — /ws/echo console. // panes/websocket.js — generic WebSocket console with URL switcher, presets,
// JSON pretty-print, auto-reconnect, message history.
import { registerPane } from '../app.js'; import { registerPane } from '../app.js';
import { escapeHtml } from '../lib/api.js';
import { ok, err, copyToClipboard, historyStore } from '../lib/ui.js';
const ENDPOINTS = [
{ label: 'echo', url: '/ws/echo' },
];
const sendHistory = historyStore('cx_ws_send_history', 30);
const TPL = ` const TPL = `
<div class="pane-head"> <div class="pane-head">
<div><div class="title">WebSocket</div><div class="sub">Connect to <code>/ws/echo</code>.</div></div> <div><div class="title">WebSocket</div><div class="sub">Connect to any WebSocket endpoint and inspect frames.</div></div>
<div class="grow"></div> <div class="grow"></div>
<span class="pill" id="ws-pill"><span class="dot"></span><span id="ws-state">idle</span></span> <span class="pill" id="ws-pill"><span class="dot"></span><span id="ws-state">idle</span></span>
</div> </div>
<div class="card">
<div class="btn-row mb-3"> <div class="card mb-3">
<div class="flex gap-2 mb-2">
<select class="input" id="ws-preset" style="max-width:160px"></select>
<input class="input" id="ws-url" placeholder="ws:// or wss:// URL"/>
<button class="btn btn-primary" id="ws-connect">Connect</button> <button class="btn btn-primary" id="ws-connect">Connect</button>
<button class="btn btn-secondary" id="ws-disconnect">Disconnect</button> <button class="btn btn-secondary" id="ws-disconnect">Disconnect</button>
</div> </div>
<pre class="console h-64" id="ws-log"></pre> <div class="flex gap-3 items-center" style="flex-wrap:wrap">
<form id="ws-form" class="flex gap-2 mt-3"> <label class="check"><input type="checkbox" id="ws-autoreconnect"/> auto-reconnect</label>
<input class="input" id="ws-input" placeholder="message…" /> <label class="check"><input type="checkbox" id="ws-pretty" checked/> pretty-print JSON</label>
<button class="btn btn-primary" type="submit">Send</button> <label class="check"><input type="checkbox" id="ws-ts" checked/> show timestamps</label>
</form> <span class="muted xsmall" id="ws-stats">0 in · 0 out · 0 b</span>
<div class="grow"></div>
<button class="btn btn-ghost" id="ws-clear">Clear log</button>
<button class="btn btn-ghost" id="ws-copy">Copy log</button>
</div>
</div>
<div class="grid" style="grid-template-columns: 1fr 280px; gap: 16px;">
<div class="card">
<div class="card-title"><h2>Frames</h2><span class="muted mono xsmall" id="ws-frame-count">0</span></div>
<pre class="console" id="ws-log" style="height:48vh; max-height:60vh; overflow:auto"></pre>
<form id="ws-form" class="flex gap-2 mt-3">
<textarea class="input" id="ws-input" rows="2" placeholder="message — Enter to send, Shift+Enter for newline"></textarea>
<button class="btn btn-primary" type="submit">Send</button>
</form>
</div>
<div class="card">
<div class="card-title"><h2>Send presets</h2></div>
<div class="btn-row mb-3">
<button class="btn btn-secondary" data-q='ping'>ping</button>
<button class="btn btn-secondary" data-q='{"cmd":"hello"}'>hello (JSON)</button>
<button class="btn btn-secondary" data-q='subscribe events'>subscribe</button>
</div>
<div class="card-title"><h2>History</h2><span class="muted xsmall" id="ws-hist-count">0</span></div>
<div id="ws-history" style="max-height:30vh; overflow:auto"></div>
</div>
</div> </div>
`; `;
let ws = null; let ws = null;
let stats = { in: 0, out: 0, bytes: 0 };
let reconnectTimer = null;
function setState(host, state, klass) { function setState(host, state, klass) {
host.querySelector('#ws-state').textContent = state; host.querySelector('#ws-state').textContent = state;
const p = host.querySelector('#ws-pill'); const p = host.querySelector('#ws-pill');
p.classList.remove('ok', 'err', 'warn', 'info'); p.classList.remove('ok', 'err', 'warn', 'info', 'muted');
if (klass) p.classList.add(klass); p.classList.add(klass || 'muted');
} }
function append(host, line) { function updateStats(host) {
host.querySelector('#ws-stats').textContent = `${stats.in} in · ${stats.out} out · ${stats.bytes} b`;
host.querySelector('#ws-frame-count').textContent = `${stats.in + stats.out}`;
}
function maybePretty(host, raw) {
if (!host.querySelector('#ws-pretty').checked) return raw;
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
}
function append(host, dir, raw) {
const pre = host.querySelector('#ws-log'); const pre = host.querySelector('#ws-log');
pre.textContent += line + '\n'; const showTs = host.querySelector('#ws-ts').checked;
const text = maybePretty(host, raw);
const ts = showTs ? `[${new Date().toISOString().slice(11, 23)}] ` : '';
const arrow = dir === 'in' ? '←' : dir === 'out' ? '→' : '·';
pre.textContent += `${ts}${arrow} ${text}\n`;
pre.scrollTop = pre.scrollHeight; pre.scrollTop = pre.scrollHeight;
} }
function defaultUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/ws/echo`;
}
function connect(host) { function connect(host) {
if (ws && ws.readyState <= 1) return; if (ws && ws.readyState <= 1) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; let url = host.querySelector('#ws-url').value.trim();
const url = `${proto}//${location.host}/ws/echo`; if (url.startsWith('/')) {
ws = new WebSocket(url); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
url = `${proto}//${location.host}${url}`;
}
if (!url) url = defaultUrl();
setState(host, 'connecting…', 'warn'); setState(host, 'connecting…', 'warn');
ws.onopen = () => { setState(host, 'connected', 'ok'); append(host, `< connected ${url}`); }; append(host, '·', `connecting ${url}`);
ws.onclose = () => { setState(host, 'disconnected', 'err'); append(host, '< closed'); }; try { ws = new WebSocket(url); } catch (e) { setState(host, 'error', 'err'); append(host, '·', e.message); return; }
ws.onerror = () => append(host, '! error'); ws.onopen = () => { setState(host, 'connected', 'ok'); append(host, '·', 'connected'); };
ws.onmessage = (e) => append(host, '< ' + e.data); ws.onclose = (e) => {
setState(host, `closed (${e.code})`, 'err');
append(host, '·', `closed code=${e.code} reason=${e.reason}`);
if (host.querySelector('#ws-autoreconnect').checked) {
reconnectTimer = setTimeout(() => connect(host), 2000);
}
};
ws.onerror = () => append(host, '·', 'error');
ws.onmessage = (e) => { stats.in++; stats.bytes += (e.data?.length || 0); updateStats(host); append(host, 'in', e.data); };
}
function disconnect() {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
try { ws?.close(); } catch {}
}
function renderHistory(host) {
const items = sendHistory.list();
host.querySelector('#ws-hist-count').textContent = String(items.length);
host.querySelector('#ws-history').innerHTML = items.length
? items.map((h, i) => `<button class="nav-item" data-hi="${i}"><span class="truncate" style="font-size:12px">${escapeHtml(h.text)}</span></button>`).join('')
: '<div class="muted xsmall p-2">empty</div>';
host.querySelectorAll('#ws-history [data-hi]').forEach(b => {
b.addEventListener('click', () => {
host.querySelector('#ws-input').value = sendHistory.list()[+b.dataset.hi]?.text || '';
});
});
}
function send(host, text) {
if (!ws || ws.readyState !== 1) return err('not connected');
ws.send(text);
stats.out++; stats.bytes += text.length; updateStats(host);
append(host, 'out', text);
sendHistory.push({ text });
renderHistory(host);
} }
registerPane('websocket', { registerPane('websocket', {
label: 'WebSocket', label: 'WebSocket',
init(host) { init(host) {
host.innerHTML = TPL; host.innerHTML = TPL;
const sel = host.querySelector('#ws-preset');
sel.innerHTML = ENDPOINTS.map((e, i) => `<option value="${i}">${escapeHtml(e.label)} (${escapeHtml(e.url)})</option>`).join('');
host.querySelector('#ws-url').value = defaultUrl();
sel.addEventListener('change', () => {
const e = ENDPOINTS[+sel.value]; if (!e) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
host.querySelector('#ws-url').value = `${proto}//${location.host}${e.url}`;
});
host.querySelector('#ws-connect').addEventListener('click', () => connect(host)); host.querySelector('#ws-connect').addEventListener('click', () => connect(host));
host.querySelector('#ws-disconnect').addEventListener('click', () => ws && ws.close()); host.querySelector('#ws-disconnect').addEventListener('click', disconnect);
host.querySelector('#ws-clear').addEventListener('click', () => {
host.querySelector('#ws-log').textContent = '';
stats = { in: 0, out: 0, bytes: 0 }; updateStats(host);
});
host.querySelector('#ws-copy').addEventListener('click', () => copyToClipboard(host.querySelector('#ws-log').textContent));
host.querySelector('#ws-form').addEventListener('submit', (e) => { host.querySelector('#ws-form').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const v = host.querySelector('#ws-input').value; const v = host.querySelector('#ws-input').value;
if (!v) return; if (!v) return;
if (!ws || ws.readyState !== 1) { append(host, '! not connected'); return; } send(host, v);
ws.send(v);
append(host, '> ' + v);
host.querySelector('#ws-input').value = ''; host.querySelector('#ws-input').value = '';
}); });
host.querySelector('#ws-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host.querySelector('#ws-form').requestSubmit(); }
});
host.querySelectorAll('[data-q]').forEach(b => b.addEventListener('click', () => send(host, b.dataset.q)));
renderHistory(host);
updateStats(host);
}, },
}); });