feat: add safe miner control foundation
This commit is contained in:
1 parent
6cd876b4b3
commit
f5965af914
39 files changed
+2952
-491
No files matched your search
@@ -48,6 +48,38 @@ def miner_detail_content() -> str:
|
||||
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel miner-controls" id="minerControlsSection"
|
||||
aria-labelledby="minerControlsTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
|
||||
<h2 id="minerControlsTitle">Controls</h2></div>
|
||||
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
|
||||
</div>
|
||||
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
|
||||
Loading factual capability and recent operations.
|
||||
</p>
|
||||
<div id="minerControlCapabilities" class="control-capability-grid"></div>
|
||||
<div id="minerControlActions" class="miner-control-actions" hidden>
|
||||
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
|
||||
<form id="minerPerformanceControl" class="performance-control" hidden>
|
||||
<label for="minerPerformanceMode">Performance mode</label>
|
||||
<select id="minerPerformanceMode">
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<button class="command-button" type="submit">Set mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<p id="minerControlPermission" class="permission-note" hidden>
|
||||
Your account can inspect control capability and history but cannot queue controls.
|
||||
</p>
|
||||
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
|
||||
<div class="table-wrap compact-history-table">
|
||||
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
|
||||
<th>Verification</th><th>Created</th></tr></thead>
|
||||
<tbody id="minerControlHistory"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
|
||||
<h2 id="minerLocationTitle">Location</h2></div></div>
|
||||
|
||||
@@ -1722,3 +1722,52 @@ button.map-position:hover {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
.control-capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.control-capability-grid > div {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.miner-control-actions,
|
||||
.performance-control {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.miner-control-actions {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.performance-control label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-callout {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-left: 4px solid var(--warn);
|
||||
background: #fff8e6;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.control-capability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.miner-control-actions,
|
||||
.performance-control {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const minerId = document.body.dataset.minerId;
|
||||
const returnTo = document.body.dataset.returnTo || "";
|
||||
const canControl = document.body.dataset.canControlMiners === "true";
|
||||
const el = {
|
||||
loading: document.getElementById("minerDetailLoading"),
|
||||
error: document.getElementById("minerDetailError"),
|
||||
@@ -32,7 +33,17 @@
|
||||
identityHistory: document.getElementById("minerIdentityHistory"),
|
||||
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
|
||||
observations: document.getElementById("minerObservations"),
|
||||
observationsBody: document.getElementById("minerObservationsBody")
|
||||
observationsBody: document.getElementById("minerObservationsBody"),
|
||||
controlsRefresh: document.getElementById("minerControlsRefresh"),
|
||||
controlsSummary: document.getElementById("minerControlsSummary"),
|
||||
controlCapabilities: document.getElementById("minerControlCapabilities"),
|
||||
controlActions: document.getElementById("minerControlActions"),
|
||||
controlPermission: document.getElementById("minerControlPermission"),
|
||||
controlResult: document.getElementById("minerControlResult"),
|
||||
serviceControl: document.getElementById("minerServiceControl"),
|
||||
performanceForm: document.getElementById("minerPerformanceControl"),
|
||||
performanceMode: document.getElementById("minerPerformanceMode"),
|
||||
controlHistory: document.getElementById("minerControlHistory")
|
||||
};
|
||||
|
||||
function create(tag, className, text) {
|
||||
@@ -121,6 +132,123 @@
|
||||
return payload ? payload.data : null;
|
||||
}
|
||||
|
||||
async function mutate(path, body) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.assign("/login?next=" + encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
));
|
||||
throw new Error("Login required");
|
||||
}
|
||||
let payload = null;
|
||||
try { payload = await response.json(); } catch (_error) { payload = null; }
|
||||
if (!response.ok) {
|
||||
throw new Error(payload && payload.detail
|
||||
? String(payload.detail) : "Request failed (" + response.status + ")");
|
||||
}
|
||||
return payload ? payload.data : null;
|
||||
}
|
||||
|
||||
function capabilityText(capability) {
|
||||
if (!capability) return "Unknown";
|
||||
const reason = capability.reason ? " / " + words(capability.reason) : "";
|
||||
return words(capability.state) + reason;
|
||||
}
|
||||
|
||||
function operationStatus(operation) {
|
||||
if (operation.status === "pending") return "Pending";
|
||||
if (operation.status === "claimed") return "Running";
|
||||
if (operation.status === "failed") return "Failed";
|
||||
if (operation.status === "cancelled") return "Cancelled";
|
||||
if (operation.verification_status === "confirmed") return "Confirmed";
|
||||
if (operation.verification_status === "unconfirmed") return "Unconfirmed";
|
||||
if (operation.verification_status === "verification_failed") return "Verification failed";
|
||||
if (operation.verification_status === "unavailable") return "Verification unavailable";
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
function renderControls(data) {
|
||||
el.controlsSummary.textContent = "Runtime: " + words(data.runtime_state) +
|
||||
" / Known mode: " + words(data.performance_mode);
|
||||
el.controlCapabilities.replaceChildren();
|
||||
[["Agent", data.agent], ["Service control", data.service_control],
|
||||
["Performance mode", data.performance_mode_control]].forEach(function (item) {
|
||||
const block = create("div");
|
||||
block.append(create("span", "secondary", item[0]),
|
||||
create("strong", "", capabilityText(item[1])));
|
||||
el.controlCapabilities.appendChild(block);
|
||||
});
|
||||
|
||||
el.controlPermission.hidden = canControl;
|
||||
el.controlActions.hidden = !canControl;
|
||||
el.serviceControl.hidden = true;
|
||||
el.performanceForm.hidden = true;
|
||||
if (canControl && data.available_service_action) {
|
||||
el.serviceControl.dataset.action = data.available_service_action;
|
||||
el.serviceControl.textContent = data.available_service_action === "suspend"
|
||||
? "Suspend miner" : "Resume miner";
|
||||
el.serviceControl.hidden = false;
|
||||
}
|
||||
if (canControl && data.performance_mode_control.state === "supported") {
|
||||
if (data.performance_mode) el.performanceMode.value = data.performance_mode;
|
||||
el.performanceForm.hidden = false;
|
||||
}
|
||||
|
||||
el.controlHistory.replaceChildren();
|
||||
(data.recent_operations || []).forEach(function (operation) {
|
||||
const row = create("tr");
|
||||
[words(operation.action), display(operation.requested_value, "None"),
|
||||
operationStatus(operation), words(operation.verification_status),
|
||||
dateTime(operation.created_at)].forEach(function (value) {
|
||||
row.appendChild(create("td", "", value));
|
||||
});
|
||||
el.controlHistory.appendChild(row);
|
||||
});
|
||||
if (!(data.recent_operations || []).length) {
|
||||
const row = create("tr");
|
||||
const cell = create("td", "empty-copy", "No control operations recorded.");
|
||||
cell.colSpan = 5;
|
||||
row.appendChild(cell);
|
||||
el.controlHistory.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControls() {
|
||||
el.controlsSummary.textContent = "Loading control capability and history.";
|
||||
try {
|
||||
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/controls");
|
||||
renderControls(data);
|
||||
} catch (error) {
|
||||
el.controlsSummary.textContent = "Controls unavailable: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueControl(action, requestedValue) {
|
||||
const description = action === "performance_mode"
|
||||
? "set performance mode to " + requestedValue : action + " this miner";
|
||||
if (!window.confirm("Confirm: " + description + "?")) return;
|
||||
const pathAction = action === "performance_mode" ? "performance-mode" : action;
|
||||
const body = action === "performance_mode" ? { mode: requestedValue } : {};
|
||||
el.controlResult.hidden = false;
|
||||
el.controlResult.textContent = "Queueing control operation.";
|
||||
try {
|
||||
const operation = await mutate(
|
||||
"/api/miners/" + encodeURIComponent(minerId) + "/controls/" + pathAction,
|
||||
body
|
||||
);
|
||||
el.controlResult.textContent = "Pending: " + words(operation.action) +
|
||||
" operation " + operation.operation_id + ".";
|
||||
await loadControls();
|
||||
} catch (error) {
|
||||
el.controlResult.textContent = "Control was not queued: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
const wrap = create("div", "table-wrap compact-history-table");
|
||||
const tableNode = create("table");
|
||||
@@ -413,8 +541,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadOverview();
|
||||
loadControls();
|
||||
}
|
||||
|
||||
el.retry.addEventListener("click", loadOverview);
|
||||
el.refresh.addEventListener("click", loadOverview);
|
||||
el.refresh.addEventListener("click", refreshAll);
|
||||
el.controlsRefresh.addEventListener("click", loadControls);
|
||||
el.serviceControl.addEventListener("click", function () {
|
||||
queueControl(el.serviceControl.dataset.action, null);
|
||||
});
|
||||
el.performanceForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
queueControl("performance_mode", el.performanceMode.value);
|
||||
});
|
||||
el.identityHistory.addEventListener("toggle", function () {
|
||||
if (el.identityHistory.open) loadIdentityHistory();
|
||||
});
|
||||
@@ -422,4 +563,5 @@
|
||||
if (el.observations.open) loadObservations();
|
||||
});
|
||||
loadOverview();
|
||||
loadControls();
|
||||
})();
|
||||
@@ -521,6 +521,7 @@ def render_app_page(
|
||||
</head>
|
||||
<body data-view="{escape(view)}"
|
||||
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||
data-can-control-miners="{str(has_permission("miners.control")).lower()}"
|
||||
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
|
||||
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
||||
<div class="app-shell">
|
||||
|
||||
Reference in new issue
Block a user