feat(cxwebapp): comprehensive pane enhancements
Some checks are pending
build-and-push / image (push) Waiting to run
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:
parent
d057e09fa2
commit
75153b7fe9
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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/<service>/*</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><body></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'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 class="scroll" style="max-height: 50vh">
|
</div>
|
||||||
|
<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); },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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="grid" style="grid-template-columns: 240px 1fr; gap: 16px;">
|
||||||
|
<div class="card no-pad" style="padding: 8px;">
|
||||||
|
<div class="card-title" style="padding: 8px 10px;"><h2 style="font-size:13px">Presets</h2></div>
|
||||||
|
<div id="ax-presets" style="display:flex; flex-direction:column; gap:2px"></div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<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>
|
||||||
|
<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">
|
<div class="flex gap-2 mb-3">
|
||||||
<select class="input" id="ax-method" style="max-width:120px">
|
<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>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option><option>HEAD</option>
|
||||||
</select>
|
</select>
|
||||||
<input class="input" id="ax-path" value="/api/health" placeholder="/api/…"/>
|
<input class="input" id="ax-path" value="/api/health" placeholder="/api/…"/>
|
||||||
<button class="btn btn-primary" id="ax-send">Send</button>
|
<button class="btn btn-primary" id="ax-send">Send</button>
|
||||||
<button class="btn btn-ghost" id="ax-clear">Clear</button>
|
|
||||||
</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>
|
<div class="seg mb-3" id="ax-tabs">
|
||||||
<pre id="ax-out" class="muted">no request yet</pre>
|
<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>
|
</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 = '—';
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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"></div>
|
<div class="grow muted small mono truncate" style="margin-left:8px">${escapeHtml(svc.health)}</div>
|
||||||
<span class="mono muted">${text}</span>
|
<div style="width:120px">${sparkline(history, { w: 120, h: 22 })}</div>
|
||||||
</div>
|
<span class="mono muted xsmall" style="width:110px;text-align:right">${text}</span>
|
||||||
`;
|
</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="grow"><div class="ttl">${escapeHtml(it.name)}</div><div class="desc">${escapeHtml(it.description || '')}</div></div>
|
||||||
<div class="ttl">${escapeHtml(it.name)}</div>
|
</div>`).join('')
|
||||||
<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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,57 +20,116 @@ 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,
|
||||||
@ -68,27 +137,38 @@ registerPane('diffusion', {
|
|||||||
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
236
static/js/panes/files.js
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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>
|
||||||
</form>
|
<input type="range" class="slider" id="inbox-threshold" min="0" max="1" step="0.05" value="0.7" style="width:100%"/>
|
||||||
<div class="mt-3">
|
<div id="inbox-thr-meter"></div>
|
||||||
<h3 class="mb-2">Last result</h3>
|
|
||||||
<pre id="inbox-result" class="muted">no sweep yet</pre>
|
|
||||||
</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>
|
||||||
</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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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="grid" style="grid-template-columns: 320px 1fr; gap: 16px;">
|
||||||
|
<div class="card no-pad scroll" style="max-height:74vh">
|
||||||
|
<div class="p-2"><input class="input" id="lang-search" placeholder="filter pipelines…"/></div>
|
||||||
|
<div id="lang-pipelines">loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title"><h2>Pipelines</h2></div>
|
<div class="card-title">
|
||||||
<div id="lang-pipelines" class="mb-3">…</div>
|
<h2 id="lang-name">Select a pipeline</h2>
|
||||||
<div class="grid gap-3">
|
<span class="muted xsmall" id="lang-status">—</span>
|
||||||
<label class="field"><span class="lbl">Pipeline</span><select class="input" id="lang-pipeline"></select></label>
|
</div>
|
||||||
<label class="field"><span class="lbl">Input (JSON)</span><textarea class="input" id="lang-input" rows="6">{}</textarea></label>
|
<p class="muted small" id="lang-desc">Pick a pipeline on the left.</p>
|
||||||
<div class="btn-row">
|
|
||||||
|
<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-primary" id="lang-run">Run</button>
|
||||||
<button class="btn btn-secondary" id="lang-health">Health</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>
|
||||||
</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 class="card">
|
|
||||||
<div class="card-title"><h2>Result</h2></div>
|
|
||||||
<pre id="lang-result" class="muted">—</pre>
|
|
||||||
</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,
|
||||||
|
|||||||
@ -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">
|
||||||
<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>Latest build</h2>
|
||||||
<pre id="mac-info">…</pre>
|
<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-title"><h2>Raw /api/mac/info</h2><button class="btn btn-ghost" id="mac-copy-json">Copy</button></div>
|
||||||
|
<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,
|
||||||
|
|||||||
@ -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([
|
||||||
@ -44,9 +76,18 @@ async function refresh(host) {
|
|||||||
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,6 +98,12 @@ 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 = {
|
||||||
@ -65,11 +112,16 @@ registerPane('slack', {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 & 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: [] }))]); }
|
||||||
|
catch (e) { host.querySelector('#sys-s').textContent = 'error: ' + e.message; return; }
|
||||||
|
|
||||||
host.querySelector('#sys-v').textContent = fmtJSON(v);
|
host.querySelector('#sys-v').textContent = fmtJSON(v);
|
||||||
host.querySelector('#sys-s').textContent = fmtJSON(s);
|
host.querySelector('#sys-s').textContent = fmtJSON(s);
|
||||||
if (s.uptime_seconds != null) host.querySelector('#sys-up').textContent = formatUptime(s.uptime_seconds);
|
|
||||||
} catch (e) {
|
// KPIs
|
||||||
host.querySelector('#sys-v').textContent = 'error: ' + e.message;
|
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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
<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 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,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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">
|
||||||
|
<label class="check"><input type="checkbox" id="ws-autoreconnect"/> auto-reconnect</label>
|
||||||
|
<label class="check"><input type="checkbox" id="ws-pretty" checked/> pretty-print JSON</label>
|
||||||
|
<label class="check"><input type="checkbox" id="ws-ts" checked/> show timestamps</label>
|
||||||
|
<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">
|
<form id="ws-form" class="flex gap-2 mt-3">
|
||||||
<input class="input" id="ws-input" placeholder="message…" />
|
<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>
|
<button class="btn btn-primary" type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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;
|
||||||
|
let url = host.querySelector('#ws-url').value.trim();
|
||||||
|
if (url.startsWith('/')) {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const url = `${proto}//${location.host}/ws/echo`;
|
url = `${proto}//${location.host}${url}`;
|
||||||
ws = new WebSocket(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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user