345 lines
10 KiB
HTML
345 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
============================================================
|
|
ARGONAUT 3 — DATA LAKE INSPECTOR
|
|
Widget 0 — Diagnostic tool (not a production widget)
|
|
============================================================
|
|
Purpose : Show all Cockpit data lake variables with live
|
|
updating values. Used during development to
|
|
discover what MAVLink data is available.
|
|
Import : Cockpit → Edit mode → Add DIY Widget →
|
|
gear icon → Import → select this file
|
|
Version : 1.1 (added scrolling, compact layout, fix)
|
|
============================================================
|
|
-->
|
|
<style>
|
|
/* ── Reset and root variables ──────────────────────────── */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
:root {
|
|
--bg0: #09111a; /* darkest background */
|
|
--bg1: #0e1d2e; /* table row background */
|
|
--bg2: #162336; /* header / input background */
|
|
--border: #1e3050; /* subtle border */
|
|
--text0: #d8eeff; /* primary text */
|
|
--text1: #6a9bbf; /* secondary / dim text */
|
|
--accent: #00c8f0; /* highlight colour */
|
|
--green: #00e09a; /* value flash on update */
|
|
--amber: #ffb830; /* status warning */
|
|
--red: #ff3a5a; /* status error / stale */
|
|
--mono: 'Courier New', monospace;
|
|
}
|
|
|
|
/* ── Outer wrapper — fills the widget area ────────────── */
|
|
body {
|
|
background: var(--bg0);
|
|
color: var(--text0);
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden; /* prevent body scroll — table scrolls */
|
|
}
|
|
|
|
/* ── Sticky header bar ─────────────────────────────────── */
|
|
#header {
|
|
background: var(--bg2);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 5px 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0; /* never shrink — always visible */
|
|
}
|
|
|
|
#header .title {
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
color: var(--accent);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Search / filter input */
|
|
#search {
|
|
flex: 1;
|
|
background: var(--bg0);
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
color: var(--text0);
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
outline: none;
|
|
}
|
|
|
|
#search:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
/* Live variable count */
|
|
#count {
|
|
font-size: 10px;
|
|
color: var(--text1);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Connection status dot */
|
|
#status {
|
|
font-size: 10px;
|
|
color: var(--amber);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#status.ok { color: var(--green); }
|
|
#status.err { color: var(--red); }
|
|
|
|
/* ── Scrollable table container ────────────────────────── */
|
|
#table-wrap {
|
|
flex: 1; /* take remaining height */
|
|
overflow-y: auto; /* THIS is what enables scrolling */
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Thin scrollbar styling */
|
|
#table-wrap::-webkit-scrollbar { width: 5px; }
|
|
#table-wrap::-webkit-scrollbar-track { background: var(--bg0); }
|
|
#table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
/* ── Table ─────────────────────────────────────────────── */
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
/* Sticky column headers scroll with table — not the page */
|
|
thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--bg2);
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text1);
|
|
font-size: 10px;
|
|
font-weight: normal;
|
|
padding: 3px 6px;
|
|
text-align: left;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Column widths */
|
|
thead th:nth-child(1) { width: 55%; } /* Variable name */
|
|
thead th:nth-child(2) { width: 12%; } /* Type */
|
|
thead th:nth-child(3) { width: 33%; } /* Value */
|
|
|
|
/* Rows */
|
|
tbody tr {
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
tbody tr:hover { background: var(--bg2); }
|
|
|
|
/* Flash green when a value changes */
|
|
tbody tr.flash td { color: var(--green); }
|
|
|
|
td {
|
|
padding: 3px 6px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Variable name column */
|
|
td:nth-child(1) {
|
|
color: var(--accent);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* Type column */
|
|
td:nth-child(2) {
|
|
color: var(--text1);
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* Value column */
|
|
td:nth-child(3) {
|
|
color: var(--text0);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* Empty state message */
|
|
#empty {
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: var(--text1);
|
|
font-size: 11px;
|
|
}
|
|
</style>
|
|
|
|
<body>
|
|
|
|
<!-- ── Header bar ──────────────────────────────────────── -->
|
|
<div id="header">
|
|
<span class="title">⬡ Data Lake</span>
|
|
<input id="search" type="text" placeholder="filter variables…" />
|
|
<span id="count">0 vars</span>
|
|
<span id="status">connecting…</span>
|
|
</div>
|
|
|
|
<!-- ── Scrollable table area ───────────────────────────── -->
|
|
<div id="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Variable</th>
|
|
<th>Type</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody">
|
|
<!-- Rows injected by JS -->
|
|
</tbody>
|
|
</table>
|
|
<div id="empty" style="display:none">No variables match the filter.</div>
|
|
</div>
|
|
|
|
</body>
|
|
|
|
<script>
|
|
// ── State ─────────────────────────────────────────────────
|
|
let vars = {}; // { variableName: { type, value } }
|
|
let filter = ''; // current search string (lowercased)
|
|
|
|
// ── DOM references ────────────────────────────────────────
|
|
const tbody = document.getElementById('tbody');
|
|
const countEl = document.getElementById('count');
|
|
const statusEl= document.getElementById('status');
|
|
const searchEl= document.getElementById('search');
|
|
const emptyEl = document.getElementById('empty');
|
|
|
|
// ── Format a raw value for display ───────────────────────
|
|
function fmt(v) {
|
|
if (v === null || v === undefined) return '—';
|
|
if (typeof v === 'number') {
|
|
// Integers shown as-is; floats to 4 decimal places
|
|
return Number.isInteger(v) ? String(v) : v.toFixed(4);
|
|
}
|
|
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
if (typeof v === 'object') return JSON.stringify(v).slice(0, 60);
|
|
return String(v).slice(0, 80);
|
|
}
|
|
|
|
// ── Render the table ─────────────────────────────────────
|
|
// Only re-renders rows that changed (avoids scroll reset)
|
|
function render() {
|
|
const keys = Object.keys(vars).sort();
|
|
const filtered = keys.filter(k =>
|
|
!filter || k.toLowerCase().includes(filter)
|
|
);
|
|
|
|
// Show/hide empty state
|
|
emptyEl.style.display = filtered.length === 0 ? '' : 'none';
|
|
|
|
// Update count
|
|
countEl.textContent = `${filtered.length} / ${keys.length} vars`;
|
|
|
|
// Build a map of existing rows by variable name
|
|
const existingRows = {};
|
|
tbody.querySelectorAll('tr[data-key]').forEach(row => {
|
|
existingRows[row.dataset.key] = row;
|
|
});
|
|
|
|
// Remove rows no longer in filtered set
|
|
Object.keys(existingRows).forEach(k => {
|
|
if (!filtered.includes(k)) {
|
|
existingRows[k].remove();
|
|
delete existingRows[k];
|
|
}
|
|
});
|
|
|
|
// Add or update rows (in sorted order)
|
|
filtered.forEach((key, idx) => {
|
|
const { type, value } = vars[key];
|
|
let row = existingRows[key];
|
|
|
|
if (!row) {
|
|
// Create new row
|
|
row = document.createElement('tr');
|
|
row.dataset.key = key;
|
|
row.innerHTML = `<td title="${key}">${key}</td><td></td><td></td>`;
|
|
tbody.appendChild(row);
|
|
}
|
|
|
|
// Update type and value cells
|
|
const cells = row.cells;
|
|
const newType = type || '—';
|
|
const newValue = fmt(value);
|
|
|
|
// Flash green on value change
|
|
if (cells[2].textContent !== newValue) {
|
|
row.classList.add('flash');
|
|
setTimeout(() => row.classList.remove('flash'), 400);
|
|
}
|
|
|
|
cells[1].textContent = newType;
|
|
cells[2].textContent = newValue;
|
|
});
|
|
}
|
|
|
|
// ── Poll the data lake every 500ms ───────────────────────
|
|
// getAllDataLakeVariablesInfo() returns {name: {type, value}}
|
|
function poll() {
|
|
try {
|
|
// Guard: API may not be ready immediately on load
|
|
if (typeof window.cockpit === 'undefined' ||
|
|
typeof window.cockpit.getAllDataLakeVariablesInfo !== 'function') {
|
|
statusEl.className = 'err';
|
|
statusEl.textContent = 'API not ready';
|
|
return;
|
|
}
|
|
|
|
const info = window.cockpit.getAllDataLakeVariablesInfo();
|
|
|
|
if (!info || Object.keys(info).length === 0) {
|
|
statusEl.className = '';
|
|
statusEl.textContent = 'no data';
|
|
return;
|
|
}
|
|
|
|
// Update our state from the fresh snapshot
|
|
Object.entries(info).forEach(([key, meta]) => {
|
|
vars[key] = {
|
|
type: meta?.type ?? typeof meta?.value ?? '?',
|
|
value: meta?.value ?? meta,
|
|
};
|
|
});
|
|
|
|
statusEl.className = 'ok';
|
|
statusEl.textContent = 'live';
|
|
render();
|
|
|
|
} catch (err) {
|
|
statusEl.className = 'err';
|
|
statusEl.textContent = 'error';
|
|
console.error('[DataLake Inspector]', err);
|
|
}
|
|
}
|
|
|
|
// ── Search / filter handler ───────────────────────────────
|
|
searchEl.addEventListener('input', () => {
|
|
filter = searchEl.value.trim().toLowerCase();
|
|
render();
|
|
});
|
|
|
|
// ── Start polling ─────────────────────────────────────────
|
|
// Initial poll after 300ms (allows Cockpit API to initialise)
|
|
setTimeout(poll, 300);
|
|
setInterval(poll, 500);
|
|
</script>
|