fix(web-ui): security hardening, SPA nav, and modularization
Ship the in-progress ES-module refactor of the web-ui (new static/modules/ layout, Usage/Settings pages, uplot-based dashboard) alongside a round of security and UX fixes: - main.go: add CSP + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy middleware on every response; WS CheckOrigin now requires Origin host to match Host (blocks cross-site WebSocket hijacking); upgrade client before dialing upstream so origin check runs first; fatal on unparseable AGENTMON_QUERY_BASE. - app.js: delegated click handler intercepts same-origin <a> clicks for SPA navigation (prev. every nav link caused a full page reload, dropping WS + in-memory state); delegated .copy-btn[data-copy] handler replaces inline onclick=; removed window.navigate / window.copyToClipboard globals and the duplicated handleGlobalSearch. - modules/nav-signal.js: per-route AbortController so in-flight fetches are cancelled when the user navigates away, preventing stale toasts and wasted renders. - modules/api.js: honours the nav signal by default; AbortError is silent. - modules/router.js: resets the nav controller on every route; dropped the fixed 80ms transition delay; breadcrumbs no longer emit inline onclick= (delegated handler picks them up). - modules/utils.js: renderCopyButton emits data-copy=\"...\" instead of nesting a JS string inside an HTML attribute — fixes an XSS where values containing ' broke out via ' decoding. Verified: go build clean; `node --check` clean on all modified modules; manual curl probes confirm security headers present on every response and WS upgrade returns 403 for cross-origin/missing Origin while 101 for same-origin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// ── state.js — shared live state for openclawState, swarmState, agentsState
|
||||
|
||||
import {
|
||||
getEnvelopePayload,
|
||||
getEnvelopeTS,
|
||||
normalizeAgentKey,
|
||||
} from './utils.js';
|
||||
|
||||
// ── OpenClaw & Swarm ─────────────────────────────────────
|
||||
|
||||
export let openclawState = { instances: {} };
|
||||
export let swarmState = { services: {} };
|
||||
|
||||
export function mergeOpenClawEvents(events) {
|
||||
for (const evt of events) {
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const instance = payload.instance || {};
|
||||
if (!instance.name) continue;
|
||||
|
||||
const existing = openclawState.instances[instance.name];
|
||||
const nextTS = new Date(getEnvelopeTS(evt) || 0).getTime();
|
||||
const currentTS = existing ? new Date(getEnvelopeTS(existing) || 0).getTime() : 0;
|
||||
if (!existing || nextTS >= currentTS) {
|
||||
openclawState.instances[instance.name] = evt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSwarmSnapshot(evt) {
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const services = payload.services || [];
|
||||
for (const svc of services) {
|
||||
if (svc.name) swarmState.services[svc.name] = svc;
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSwarmServiceSnapshot(evt) {
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const svc = payload.service;
|
||||
if (svc && svc.name) swarmState.services[svc.name] = svc;
|
||||
}
|
||||
|
||||
export function getK8sHomelabServices() {
|
||||
const services = [];
|
||||
for (const [name, evt] of Object.entries(openclawState.instances)) {
|
||||
const payload = getEnvelopePayload(evt);
|
||||
if (!payload.minio) continue;
|
||||
|
||||
const minio = payload.minio;
|
||||
services.push({
|
||||
name: 'minio-storage',
|
||||
role: 'storage',
|
||||
category: 'k8s homelab',
|
||||
sourceInstance: name,
|
||||
status: minio.reachable ? 'healthy' : 'down',
|
||||
endpoint: minio.endpoint || '',
|
||||
bucket: minio.bucket || '',
|
||||
prefix: minio.prefix || '',
|
||||
objectCount: minio.object_count,
|
||||
totalBytes: minio.total_bytes,
|
||||
latestBackup: minio.latest_backup || '',
|
||||
httpStatus: minio.http_status,
|
||||
error: minio.error || '',
|
||||
});
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
export function getVMStatus() {
|
||||
const names = Object.keys(openclawState.instances).sort();
|
||||
return names.map(name => {
|
||||
const snapshot = openclawState.instances[name];
|
||||
const payload = snapshot ? getEnvelopePayload(snapshot) : {};
|
||||
const host = payload.host || {};
|
||||
return { name, active: host.state === 'running' };
|
||||
});
|
||||
}
|
||||
|
||||
export function getDashboardInfraPill() {
|
||||
const services = Object.values(swarmState.services);
|
||||
if (services.length === 0) {
|
||||
return { className: 'inactive', name: 'infra', label: 'unknown' };
|
||||
}
|
||||
|
||||
const unhealthy = services.filter(svc => svc.status !== 'healthy');
|
||||
if (unhealthy.length === 0) {
|
||||
return { className: 'active', name: 'infra', label: 'all running' };
|
||||
}
|
||||
|
||||
const degradedOnly = unhealthy.every(svc => svc.status === 'degraded');
|
||||
return {
|
||||
className: degradedOnly ? 'degraded' : 'inactive',
|
||||
name: 'infra',
|
||||
label: degradedOnly ? 'degraded' : `${unhealthy.length} issue${unhealthy.length === 1 ? '' : 's'}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOpenClawVM(agent) {
|
||||
const key = normalizeAgentKey(agent && agent.name);
|
||||
return !!openclawState.instances[key];
|
||||
}
|
||||
|
||||
export function isAgentOnline(agent) {
|
||||
if (!agent) return false;
|
||||
|
||||
if (isOpenClawVM(agent)) {
|
||||
const vmStatus = getVMStatus().find(v => v.name === normalizeAgentKey(agent.name));
|
||||
if (vmStatus) return vmStatus.active;
|
||||
}
|
||||
|
||||
const hasSessions = Object.keys(agent.sessions).length > 0;
|
||||
const hasOps = Object.keys(agent.operations).length > 0;
|
||||
const seenRecently = agent.lastSeenAt > 0 && (Date.now() - agent.lastSeenAt) < 300000;
|
||||
return hasSessions || hasOps || seenRecently;
|
||||
}
|
||||
|
||||
// ── Agents state ─────────────────────────────────────────
|
||||
|
||||
function createAgentsState() {
|
||||
return {
|
||||
agents: {},
|
||||
stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} },
|
||||
dbStats: { messages: 0, tools: 0, errors: 0 },
|
||||
viewMode: 'overview',
|
||||
selectedAgentKey: '',
|
||||
timerInterval: null,
|
||||
};
|
||||
}
|
||||
|
||||
export let agentsState = createAgentsState();
|
||||
|
||||
export function resetAgentsState() {
|
||||
agentsState = createAgentsState();
|
||||
}
|
||||
Reference in New Issue
Block a user