561 lines
16 KiB
HTML
561 lines
16 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Agent System</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
background: #101418;
|
|
color: #e7edf3;
|
|
}
|
|
|
|
main {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0 0 16px;
|
|
font-size: 24px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
#logs {
|
|
height: 70vh;
|
|
overflow-y: auto;
|
|
border: 1px solid #2a3540;
|
|
background: #0b0f13;
|
|
padding: 12px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
#services {
|
|
border: 1px solid #2a3540;
|
|
background: #0b0f13;
|
|
padding: 12px;
|
|
margin-bottom: 12px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.panel-title {
|
|
font-weight: 700;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.service {
|
|
border-top: 1px solid #202a33;
|
|
padding: 10px 0;
|
|
}
|
|
|
|
.service:first-of-type {
|
|
border-top: 0;
|
|
padding-top: 0;
|
|
}
|
|
|
|
.service-header {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.service-name {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.service-history {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.history-entry {
|
|
border: 1px solid #2a3540;
|
|
padding: 3px 6px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
}
|
|
|
|
.run {
|
|
border: 1px solid #333;
|
|
margin: 10px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.run.stopped {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #ffffff;
|
|
font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.run-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.proposal {
|
|
border: 1px solid #4c5b2b;
|
|
background: #171d12;
|
|
color: #edf5d4;
|
|
margin: 8px 0;
|
|
padding: 10px;
|
|
}
|
|
|
|
.proposal-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.proposal-divider {
|
|
border-top: 1px solid #3c4930;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.config-panel {
|
|
border: 1px solid #2a3540;
|
|
background: #111820;
|
|
margin-top: 12px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.config-title {
|
|
font-weight: 700;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.config-grid {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(96px, 140px) auto;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
border: 1px solid #354453;
|
|
background: #151c23;
|
|
color: #e7edf3;
|
|
padding: 10px 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.toggle input {
|
|
padding: 0;
|
|
}
|
|
|
|
input, button {
|
|
font: inherit;
|
|
border: 1px solid #354453;
|
|
background: #151c23;
|
|
color: #e7edf3;
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
button {
|
|
cursor: pointer;
|
|
min-width: 88px;
|
|
}
|
|
|
|
button:hover {
|
|
background: #1d2730;
|
|
}
|
|
|
|
.error { color: #ff6b6b; }
|
|
.warning { color: #f4a261; }
|
|
.info { color: #5dade2; }
|
|
.action { color: #58d68d; }
|
|
.auto_action { color: #a7f3d0; }
|
|
.learning { color: #c4b5fd; }
|
|
.log, .result { color: #ccd6df; }
|
|
.status.running { color: yellow; }
|
|
.status.done { color: green; }
|
|
.status.error { color: red; }
|
|
.status.stopped { color: gray; }
|
|
.health-ok { color: #58d68d; }
|
|
.health-warning { color: #f4d03f; }
|
|
.health-error { color: #ff6b6b; }
|
|
.health-unknown { color: #95a5a6; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Agent System</h1>
|
|
<section id="services">
|
|
<div class="panel-title">Services</div>
|
|
<div id="services-list"></div>
|
|
</section>
|
|
<div id="logs"></div>
|
|
<div class="row">
|
|
<input id="cmd" autocomplete="off" placeholder="Command">
|
|
<button onclick="send()">Send</button>
|
|
<button id="stop">STOP</button>
|
|
</div>
|
|
<section class="config-panel">
|
|
<div class="config-title">Auto Mode Config</div>
|
|
<div class="config-grid">
|
|
<label class="toggle">
|
|
<input id="config-auto-mode" type="checkbox">
|
|
auto_mode
|
|
</label>
|
|
<span></span>
|
|
<button id="save-config">Save</button>
|
|
|
|
<label for="restart-threshold">restart_ha threshold</label>
|
|
<input id="restart-threshold" type="number" min="0" max="1" step="0.01">
|
|
<span></span>
|
|
|
|
<label for="network-threshold">check_network threshold</label>
|
|
<input id="network-threshold" type="number" min="0" max="1" step="0.01">
|
|
<span></span>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const logs = document.getElementById("logs");
|
|
const servicesList = document.getElementById("services-list");
|
|
let polling = true;
|
|
let currentConfig = {
|
|
auto_mode: true,
|
|
action_thresholds: {
|
|
restart_ha: 0.8,
|
|
check_network: 0.9
|
|
},
|
|
default_threshold: 0.9,
|
|
allowed_auto_actions: ["restart_ha"]
|
|
};
|
|
|
|
function eventClass(event) {
|
|
const message = event.message || "";
|
|
if (event.type === "action") return "action";
|
|
if (message.startsWith("[error]")) return "error";
|
|
if (message.startsWith("[warning]")) return "warning";
|
|
if (message.startsWith("[info]")) return "info";
|
|
return event.type || "log";
|
|
}
|
|
|
|
function confidenceLabel(confidence) {
|
|
if (typeof confidence !== "number") return "";
|
|
return `${Math.round(confidence * 100)}%`;
|
|
}
|
|
|
|
function healthClass(status) {
|
|
const normalized = String(status || "unknown").toLowerCase();
|
|
if (["ok", "healthy", "up"].includes(normalized)) return "health-ok";
|
|
if (["warning", "degraded"].includes(normalized)) return "health-warning";
|
|
if (["error", "down", "failed"].includes(normalized)) return "health-error";
|
|
return "health-unknown";
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
if (!timestamp) return "unknown";
|
|
return new Date(timestamp * 1000).toLocaleString();
|
|
}
|
|
|
|
async function fetchServices() {
|
|
const response = await fetch("/services", {cache: "no-store"});
|
|
const services = await response.json();
|
|
servicesList.innerHTML = "";
|
|
|
|
if (!Array.isArray(services) || services.length === 0) {
|
|
servicesList.textContent = "No service health data yet.";
|
|
return;
|
|
}
|
|
|
|
services.forEach((service) => {
|
|
const item = document.createElement("div");
|
|
item.className = "service";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "service-header";
|
|
|
|
const name = document.createElement("span");
|
|
name.className = "service-name";
|
|
name.textContent = service.name || "unknown";
|
|
header.appendChild(name);
|
|
|
|
const status = document.createElement("span");
|
|
status.className = healthClass(service.status);
|
|
status.textContent = String(service.status || "unknown").toUpperCase();
|
|
header.appendChild(status);
|
|
item.appendChild(header);
|
|
|
|
const lastCheck = document.createElement("div");
|
|
lastCheck.textContent = `Last check: ${formatTime(service.last_check)}`;
|
|
item.appendChild(lastCheck);
|
|
|
|
const history = document.createElement("div");
|
|
history.className = "service-history";
|
|
(service.history || []).slice(-5).forEach((entry) => {
|
|
const node = document.createElement("span");
|
|
node.className = `history-entry ${healthClass(entry.status)}`;
|
|
node.textContent = `${entry.status || "unknown"} ${formatTime(entry.timestamp)}`;
|
|
history.appendChild(node);
|
|
});
|
|
item.appendChild(history);
|
|
|
|
servicesList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function fetchLogs() {
|
|
if (!polling) return;
|
|
const response = await fetch("/logs", {cache: "no-store"});
|
|
const text = await response.text();
|
|
logs.innerHTML = "";
|
|
|
|
const runs = {};
|
|
text.trim().split("\n").filter(Boolean).forEach((line) => {
|
|
let event;
|
|
try {
|
|
event = JSON.parse(line);
|
|
} catch {
|
|
event = {
|
|
type: "log",
|
|
message: line,
|
|
run_id: "legacy",
|
|
timestamp: Date.now() / 1000
|
|
};
|
|
}
|
|
|
|
const runId = event.run_id || "no-run";
|
|
if (!runs[runId]) {
|
|
runs[runId] = {
|
|
events: [],
|
|
status: "running",
|
|
received: 0,
|
|
expected: 0
|
|
};
|
|
}
|
|
|
|
const run = runs[runId];
|
|
run.events.push(event);
|
|
|
|
if (event.type === "run_status") {
|
|
run.status = event.status || run.status;
|
|
} else if (event.type === "run_progress") {
|
|
run.received = Number(event.received) || 0;
|
|
run.expected = Number(event.expected) || 0;
|
|
}
|
|
});
|
|
|
|
Object.entries(runs).forEach(([runId, runState]) => {
|
|
const run = document.createElement("div");
|
|
run.className = "run";
|
|
const status = runState.status;
|
|
if (status === "stopped") {
|
|
run.classList.add("stopped");
|
|
}
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "header";
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "run-title";
|
|
title.appendChild(document.createTextNode(`RUN ${runId} [`));
|
|
|
|
const statusNode = document.createElement("span");
|
|
statusNode.className = `status ${status}`;
|
|
statusNode.textContent = status.toUpperCase();
|
|
title.appendChild(statusNode);
|
|
title.appendChild(
|
|
document.createTextNode(`] (${runState.received}/${runState.expected})`)
|
|
);
|
|
header.appendChild(title);
|
|
|
|
if (runId !== "no-run" && status === "running") {
|
|
const stopButton = document.createElement("button");
|
|
stopButton.textContent = "STOP";
|
|
stopButton.setAttribute("onclick", `stop(${JSON.stringify(runId)})`);
|
|
header.appendChild(stopButton);
|
|
}
|
|
|
|
run.appendChild(header);
|
|
|
|
const eventList = document.createElement("div");
|
|
eventList.className = "events";
|
|
|
|
runState.events.forEach((event) => {
|
|
if (event.type === "proposal") {
|
|
const proposal = document.createElement("div");
|
|
proposal.className = "proposal";
|
|
|
|
const message = document.createElement("div");
|
|
message.textContent = event.message || "";
|
|
proposal.appendChild(message);
|
|
|
|
const confidence = document.createElement("div");
|
|
confidence.textContent = `Confidence: ${confidenceLabel(event.confidence)}`;
|
|
proposal.appendChild(confidence);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "proposal-actions";
|
|
const allOptions = event.options || event.actions || [];
|
|
const options = allOptions.filter((option) => !option.is_ignore).sort(
|
|
(a, b) => (b.confidence || 0) - (a.confidence || 0)
|
|
);
|
|
const ignoreOptions = allOptions.filter((option) => option.is_ignore);
|
|
options.forEach((option) => {
|
|
const button = document.createElement("button");
|
|
const optionConfidence = confidenceLabel(option.confidence);
|
|
button.textContent = optionConfidence
|
|
? `${option.label || option.command} (${optionConfidence})`
|
|
: option.label || option.command;
|
|
button.setAttribute(
|
|
"onclick",
|
|
`apply(${JSON.stringify(runId)}, ${JSON.stringify(option.command)})`
|
|
);
|
|
actions.appendChild(button);
|
|
});
|
|
proposal.appendChild(actions);
|
|
|
|
if (ignoreOptions.length) {
|
|
const divider = document.createElement("div");
|
|
divider.className = "proposal-divider";
|
|
proposal.appendChild(divider);
|
|
|
|
const ignoreActions = document.createElement("div");
|
|
ignoreActions.className = "proposal-actions";
|
|
ignoreOptions.forEach((option) => {
|
|
const button = document.createElement("button");
|
|
button.textContent = option.label || option.command;
|
|
button.setAttribute(
|
|
"onclick",
|
|
`apply(${JSON.stringify(runId)}, ${JSON.stringify(option.command)})`
|
|
);
|
|
ignoreActions.appendChild(button);
|
|
});
|
|
proposal.appendChild(ignoreActions);
|
|
}
|
|
|
|
eventList.appendChild(proposal);
|
|
return;
|
|
}
|
|
|
|
const div = document.createElement("div");
|
|
div.className = eventClass(event);
|
|
div.textContent = `${new Date(event.timestamp * 1000).toLocaleTimeString()} ${event.message}`;
|
|
eventList.appendChild(div);
|
|
});
|
|
|
|
run.appendChild(eventList);
|
|
logs.appendChild(run);
|
|
});
|
|
|
|
logs.scrollTop = logs.scrollHeight;
|
|
}
|
|
|
|
async function send() {
|
|
const cmd = document.getElementById("cmd");
|
|
const value = cmd.value.trim();
|
|
if (!value) return;
|
|
await fetch("/command", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({command: value})
|
|
});
|
|
cmd.value = "";
|
|
}
|
|
|
|
async function stop(run_id) {
|
|
await fetch("/stop", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({run_id})
|
|
});
|
|
}
|
|
|
|
async function apply(run_id, command) {
|
|
await fetch("/action", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({run_id, command})
|
|
});
|
|
}
|
|
|
|
function populateConfig(config) {
|
|
currentConfig = config;
|
|
document.getElementById("config-auto-mode").checked = Boolean(config.auto_mode);
|
|
document.getElementById("restart-threshold").value =
|
|
config.action_thresholds?.restart_ha ?? "";
|
|
document.getElementById("network-threshold").value =
|
|
config.action_thresholds?.check_network ?? "";
|
|
}
|
|
|
|
async function loadConfig() {
|
|
const response = await fetch("/config", {cache: "no-store"});
|
|
populateConfig(await response.json());
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const restartThreshold = Number(document.getElementById("restart-threshold").value);
|
|
const networkThreshold = Number(document.getElementById("network-threshold").value);
|
|
if (!Number.isFinite(restartThreshold) || !Number.isFinite(networkThreshold)) {
|
|
return;
|
|
}
|
|
|
|
const config = {
|
|
auto_mode: document.getElementById("config-auto-mode").checked,
|
|
action_thresholds: {
|
|
...(currentConfig.action_thresholds || {}),
|
|
restart_ha: restartThreshold,
|
|
check_network: networkThreshold
|
|
},
|
|
default_threshold: currentConfig.default_threshold ?? 0.9,
|
|
allowed_auto_actions: currentConfig.allowed_auto_actions || ["restart_ha"]
|
|
};
|
|
|
|
await fetch("/config", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify(config)
|
|
});
|
|
populateConfig(config);
|
|
}
|
|
|
|
document.getElementById("stop").addEventListener("click", async () => {
|
|
await fetch("/command", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({command: "stop"})
|
|
});
|
|
});
|
|
document.getElementById("save-config").addEventListener("click", saveConfig);
|
|
document.getElementById("cmd").addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") send();
|
|
});
|
|
|
|
loadConfig();
|
|
fetchServices();
|
|
fetchLogs();
|
|
setInterval(fetchLogs, 1000);
|
|
setInterval(fetchServices, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|