embedded-lab-control 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Activity — Lab Controller{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block head %}
|
|
6
|
+
<style>
|
|
7
|
+
.activity-page { max-width: 1400px; }
|
|
8
|
+
.activity-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1rem; }
|
|
9
|
+
.activity-filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
10
|
+
.filter-chip { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.2rem 0.6rem; border-radius: 999px; background: #e9ecef; cursor: pointer; font-size: 0.85rem; border: 1px solid transparent; }
|
|
11
|
+
.filter-chip.active { background: #0d6efd; color: white; }
|
|
12
|
+
.filter-chip.source-cli.active { background: #0d6efd; }
|
|
13
|
+
.filter-chip.source-mcp.active { background: #6f42c1; }
|
|
14
|
+
.filter-chip.source-api.active { background: #20c997; }
|
|
15
|
+
.filter-chip.source-daemon.active { background: #6c757d; }
|
|
16
|
+
.filter-chip.source-internal.active { background: #adb5bd; color: #212529; }
|
|
17
|
+
.activity-status { font-size: 0.8rem; color: #6c757d; margin-left: 1rem; }
|
|
18
|
+
.activity-status.live::before { content: "●"; color: #28a745; margin-right: 0.3rem; }
|
|
19
|
+
.activity-status.stale::before { content: "●"; color: #dc3545; margin-right: 0.3rem; }
|
|
20
|
+
.activity-feed { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.85rem; border: 1px solid #dee2e6; border-radius: 4px; background: #fafafa; max-height: 70vh; overflow-y: auto; }
|
|
21
|
+
.activity-row { display: grid; grid-template-columns: 180px 200px 80px 160px 160px 80px 1fr; gap: 0.5rem; padding: 0.35rem 0.6rem; border-bottom: 1px solid #eee; align-items: baseline; }
|
|
22
|
+
.activity-row:last-child { border-bottom: none; }
|
|
23
|
+
.activity-row.row-new { animation: flash-new 1.2s ease-out; }
|
|
24
|
+
@keyframes flash-new { from { background: #fff3cd; } to { background: transparent; } }
|
|
25
|
+
.col-ts { color: #6c757d; }
|
|
26
|
+
.col-actor { color: #0d6efd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
27
|
+
.col-source { font-size: 0.75rem; padding: 0.1rem 0.4rem; border-radius: 3px; background: #e9ecef; text-align: center; color: #495057; }
|
|
28
|
+
.col-source.source-cli { background: #cfe2ff; color: #084298; }
|
|
29
|
+
.col-source.source-mcp { background: #e2d9f3; color: #3d2a7e; }
|
|
30
|
+
.col-source.source-api { background: #d1f2eb; color: #0f6848; }
|
|
31
|
+
.col-source.source-daemon { background: #dee2e6; color: #495057; }
|
|
32
|
+
.col-action { font-weight: 500; }
|
|
33
|
+
.col-target { color: #495057; }
|
|
34
|
+
.col-result.result-ok { color: #28a745; }
|
|
35
|
+
.col-result.result-error { color: #dc3545; font-weight: bold; }
|
|
36
|
+
.col-result.result-forbidden { color: #fd7e14; }
|
|
37
|
+
.col-details { color: #6c757d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
38
|
+
.activity-empty { padding: 2rem; text-align: center; color: #6c757d; }
|
|
39
|
+
.pause-btn { font-size: 0.85rem; padding: 0.2rem 0.6rem; margin-left: 0.5rem; }
|
|
40
|
+
</style>
|
|
41
|
+
{% endblock %}
|
|
42
|
+
|
|
43
|
+
{% block content %}
|
|
44
|
+
<div class="activity-page">
|
|
45
|
+
<div class="activity-header">
|
|
46
|
+
<div>
|
|
47
|
+
<h1 style="display: inline-block; margin: 0;">Activity</h1>
|
|
48
|
+
<span id="activity-status" class="activity-status">connecting…</span>
|
|
49
|
+
<button id="pause-btn" class="btn btn-sm btn-secondary pause-btn">Pause</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<span id="event-count" style="font-size: 0.85rem; color: #6c757d;">0 events</span>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="activity-filters" id="filters">
|
|
57
|
+
<span style="align-self: center; font-size: 0.85rem; color: #495057;">Source:</span>
|
|
58
|
+
<span class="filter-chip source-cli" data-filter="source" data-value="cli">cli</span>
|
|
59
|
+
<span class="filter-chip source-mcp" data-filter="source" data-value="mcp">mcp</span>
|
|
60
|
+
<span class="filter-chip source-api" data-filter="source" data-value="api">api</span>
|
|
61
|
+
<span class="filter-chip source-daemon" data-filter="source" data-value="daemon">daemon</span>
|
|
62
|
+
<span class="filter-chip source-internal" data-filter="source" data-value="internal">internal</span>
|
|
63
|
+
<span style="align-self: center; font-size: 0.85rem; color: #495057; margin-left: 1rem;">Result:</span>
|
|
64
|
+
<span class="filter-chip" data-filter="result" data-value="ok">ok</span>
|
|
65
|
+
<span class="filter-chip" data-filter="result" data-value="error">error</span>
|
|
66
|
+
<span class="filter-chip" data-filter="result" data-value="forbidden">forbidden</span>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="activity-feed" id="feed">
|
|
70
|
+
<div class="activity-empty">Loading…</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<script>
|
|
75
|
+
(function() {
|
|
76
|
+
const feed = document.getElementById('feed');
|
|
77
|
+
const statusEl = document.getElementById('activity-status');
|
|
78
|
+
const countEl = document.getElementById('event-count');
|
|
79
|
+
const pauseBtn = document.getElementById('pause-btn');
|
|
80
|
+
|
|
81
|
+
// Deduplicate by id (server-rendered initial + SSE first hydration overlap).
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const maxRows = 2000;
|
|
84
|
+
let eventCount = 0;
|
|
85
|
+
let paused = false;
|
|
86
|
+
let buffered = []; // events while paused
|
|
87
|
+
|
|
88
|
+
// Active filters: { source: Set, result: Set }
|
|
89
|
+
const filters = { source: new Set(), result: new Set() };
|
|
90
|
+
|
|
91
|
+
function matchesFilters(event) {
|
|
92
|
+
if (filters.source.size && !filters.source.has(event.source)) return false;
|
|
93
|
+
if (filters.result.size && !filters.result.has(event.result)) return false;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatDetails(detailsJson) {
|
|
98
|
+
if (!detailsJson) return '';
|
|
99
|
+
try {
|
|
100
|
+
const o = JSON.parse(detailsJson);
|
|
101
|
+
if (o.message) return o.message;
|
|
102
|
+
return Object.keys(o).map(k => `${k}=${JSON.stringify(o[k])}`).join(' ');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return detailsJson;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderRow(event, isNew) {
|
|
109
|
+
const row = document.createElement('div');
|
|
110
|
+
row.className = 'activity-row' + (isNew ? ' row-new' : '');
|
|
111
|
+
row.dataset.source = event.source;
|
|
112
|
+
row.dataset.result = event.result;
|
|
113
|
+
row.innerHTML = `
|
|
114
|
+
<div class="col-ts">${event.logged_at || ''}</div>
|
|
115
|
+
<div class="col-actor" title="${event.actor}">${event.actor}</div>
|
|
116
|
+
<div class="col-source source-${event.source}">${event.source}</div>
|
|
117
|
+
<div class="col-action">${event.action}</div>
|
|
118
|
+
<div class="col-target">${event.entity_name || '-'}</div>
|
|
119
|
+
<div class="col-result result-${event.result}">${event.result}</div>
|
|
120
|
+
<div class="col-details" title="${formatDetails(event.details).replace(/"/g, '"')}">${formatDetails(event.details)}</div>
|
|
121
|
+
`;
|
|
122
|
+
if (!matchesFilters(event)) row.style.display = 'none';
|
|
123
|
+
return row;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function addEvent(event) {
|
|
127
|
+
if (seen.has(event.id)) return;
|
|
128
|
+
seen.add(event.id);
|
|
129
|
+
eventCount++;
|
|
130
|
+
countEl.textContent = `${eventCount} events`;
|
|
131
|
+
|
|
132
|
+
if (paused) {
|
|
133
|
+
buffered.push(event);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Clear "Loading..." placeholder on first render.
|
|
137
|
+
if (feed.querySelector('.activity-empty')) feed.innerHTML = '';
|
|
138
|
+
feed.prepend(renderRow(event, true));
|
|
139
|
+
// Trim oldest rows.
|
|
140
|
+
while (feed.children.length > maxRows) {
|
|
141
|
+
feed.removeChild(feed.lastChild);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applyFilters() {
|
|
146
|
+
feed.querySelectorAll('.activity-row').forEach(row => {
|
|
147
|
+
const match =
|
|
148
|
+
(!filters.source.size || filters.source.has(row.dataset.source)) &&
|
|
149
|
+
(!filters.result.size || filters.result.has(row.dataset.result));
|
|
150
|
+
row.style.display = match ? '' : 'none';
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Server-rendered initial events (oldest-first → render newest-first).
|
|
155
|
+
const initial = {{ initial_events | tojson }};
|
|
156
|
+
for (let i = initial.length - 1; i >= 0; i--) addEvent(initial[i]);
|
|
157
|
+
if (initial.length === 0) {
|
|
158
|
+
feed.innerHTML = '<div class="activity-empty">No events yet.</div>';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Filter chips.
|
|
162
|
+
document.querySelectorAll('.filter-chip').forEach(chip => {
|
|
163
|
+
chip.addEventListener('click', () => {
|
|
164
|
+
const f = chip.dataset.filter;
|
|
165
|
+
const v = chip.dataset.value;
|
|
166
|
+
if (filters[f].has(v)) { filters[f].delete(v); chip.classList.remove('active'); }
|
|
167
|
+
else { filters[f].add(v); chip.classList.add('active'); }
|
|
168
|
+
applyFilters();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Pause / resume.
|
|
173
|
+
pauseBtn.addEventListener('click', () => {
|
|
174
|
+
paused = !paused;
|
|
175
|
+
pauseBtn.textContent = paused ? `Resume (${buffered.length})` : 'Pause';
|
|
176
|
+
if (!paused) {
|
|
177
|
+
while (buffered.length) {
|
|
178
|
+
const event = buffered.shift();
|
|
179
|
+
if (feed.querySelector('.activity-empty')) feed.innerHTML = '';
|
|
180
|
+
feed.prepend(renderRow(event, true));
|
|
181
|
+
}
|
|
182
|
+
while (feed.children.length > maxRows) feed.removeChild(feed.lastChild);
|
|
183
|
+
} else {
|
|
184
|
+
setInterval(() => { if (paused) pauseBtn.textContent = `Resume (${buffered.length})`; }, 500);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// SSE connection.
|
|
189
|
+
const es = new EventSource('{{ url_for("views.activity_stream") }}');
|
|
190
|
+
es.onopen = () => { statusEl.className = 'activity-status live'; statusEl.textContent = 'live'; };
|
|
191
|
+
es.onerror = () => { statusEl.className = 'activity-status stale'; statusEl.textContent = 'reconnecting…'; };
|
|
192
|
+
es.addEventListener('activity', (ev) => {
|
|
193
|
+
try {
|
|
194
|
+
const event = JSON.parse(ev.data);
|
|
195
|
+
addEvent(event);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.warn('bad activity event', e);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
})();
|
|
201
|
+
</script>
|
|
202
|
+
{% endblock %}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{% block title %}Lab Controller{% endblock %}</title>
|
|
7
|
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
8
|
+
{% block head %}{% endblock %}
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<nav class="navbar">
|
|
12
|
+
<div class="nav-brand">
|
|
13
|
+
<a href="{{ url_for('views.index') }}">Lab Controller</a>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="nav-links">
|
|
16
|
+
<a href="{{ url_for('views.index') }}" class="nav-link">Dashboard</a>
|
|
17
|
+
<a href="{{ url_for('views.activity') }}" class="nav-link">Activity</a>
|
|
18
|
+
<a href="{{ url_for('views.settings') }}" class="nav-link">Settings</a>
|
|
19
|
+
<a href="{{ url_for('api.health_check') }}" class="nav-link">API</a>
|
|
20
|
+
{% if session.get('user') %}
|
|
21
|
+
<span class="nav-user">{{ session['user'] }}</span>
|
|
22
|
+
<form action="{{ url_for('auth.logout') }}" method="post" class="inline-form nav-logout">
|
|
23
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
24
|
+
<button type="submit" class="btn btn-sm btn-secondary">Logout</button>
|
|
25
|
+
</form>
|
|
26
|
+
{% endif %}
|
|
27
|
+
</div>
|
|
28
|
+
</nav>
|
|
29
|
+
|
|
30
|
+
<main class="container">
|
|
31
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
32
|
+
{% if messages %}
|
|
33
|
+
{% for category, message in messages %}
|
|
34
|
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
|
35
|
+
{% endfor %}
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% endwith %}
|
|
38
|
+
|
|
39
|
+
{% block content %}{% endblock %}
|
|
40
|
+
</main>
|
|
41
|
+
|
|
42
|
+
<footer class="footer">
|
|
43
|
+
<p>Lab Controller v{{ labctl_version }}</p>
|
|
44
|
+
</footer>
|
|
45
|
+
|
|
46
|
+
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
|
47
|
+
{% block scripts %}{% endblock %}
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{{ sbc_name }} is claimed - Lab Controller{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="page-header">
|
|
7
|
+
<a href="{{ back }}" class="back-link">← Back to {{ sbc_name }}</a>
|
|
8
|
+
<h1>Not changed: {{ sbc_name }}</h1>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="claim-section">
|
|
12
|
+
<h3>{% if body.error == "sbc_claimed" %}Claimed by another user{% else %}Not allowed{% endif %}</h3>
|
|
13
|
+
<p>{{ body.message }}</p>
|
|
14
|
+
{% if body.claim and body.claim.reason %}
|
|
15
|
+
<div class="info-row">
|
|
16
|
+
<span class="label">Reason:</span>
|
|
17
|
+
<span class="value">{{ body.claim.reason }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
{% endif %}
|
|
20
|
+
{% if body.error == "sbc_claimed" and body.claim and body.claim.is_active %}
|
|
21
|
+
<p>Wait for the claim to end, or ask its holder to release it
|
|
22
|
+
(<code>labctl request-release {{ body.sbc_name }} -r REASON</code>).</p>
|
|
23
|
+
{% elif body.error == "sbc_claimed" %}
|
|
24
|
+
<p>The claim has expired but is in its grace period; retry once it ends.</p>
|
|
25
|
+
{% endif %}
|
|
26
|
+
{% if can_override %}
|
|
27
|
+
<form action="{{ action }}" method="post" class="mt-2">
|
|
28
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
29
|
+
{% for key, value in fields %}
|
|
30
|
+
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
|
31
|
+
{% endfor %}
|
|
32
|
+
<input type="hidden" name="override_claim" value="1">
|
|
33
|
+
<button type="submit" class="btn btn-sm btn-danger">Override the claim and continue</button>
|
|
34
|
+
<span>(recorded in the activity log)</span>
|
|
35
|
+
</form>
|
|
36
|
+
{% endif %}
|
|
37
|
+
</div>
|
|
38
|
+
{% endblock %}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Console - {{ sbc.name }}{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block head %}
|
|
6
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
|
|
7
|
+
<style>
|
|
8
|
+
.console-container {
|
|
9
|
+
background: var(--color-surface);
|
|
10
|
+
border-radius: var(--border-radius);
|
|
11
|
+
border: 1px solid var(--color-primary);
|
|
12
|
+
padding: 1rem;
|
|
13
|
+
margin-bottom: 1rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.console-header {
|
|
17
|
+
display: flex;
|
|
18
|
+
justify-content: space-between;
|
|
19
|
+
align-items: center;
|
|
20
|
+
margin-bottom: 1rem;
|
|
21
|
+
padding-bottom: 0.5rem;
|
|
22
|
+
border-bottom: 1px solid var(--color-primary);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.console-status {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: 0.5rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.status-dot {
|
|
32
|
+
width: 10px;
|
|
33
|
+
height: 10px;
|
|
34
|
+
border-radius: 50%;
|
|
35
|
+
background: var(--color-text-muted);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.status-dot.connected {
|
|
39
|
+
background: var(--color-success);
|
|
40
|
+
box-shadow: 0 0 8px var(--color-success);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.status-dot.disconnected {
|
|
44
|
+
background: var(--color-danger);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#terminal {
|
|
48
|
+
height: 500px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.console-footer {
|
|
52
|
+
margin-top: 1rem;
|
|
53
|
+
padding-top: 0.5rem;
|
|
54
|
+
border-top: 1px solid var(--color-primary);
|
|
55
|
+
display: flex;
|
|
56
|
+
justify-content: space-between;
|
|
57
|
+
align-items: center;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.console-info {
|
|
61
|
+
color: var(--color-text-muted);
|
|
62
|
+
font-size: 0.9rem;
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
65
|
+
{% endblock %}
|
|
66
|
+
|
|
67
|
+
{% block content %}
|
|
68
|
+
<a href="{{ url_for('views.sbc_detail', name=sbc.name) }}" class="back-link">← Back to {{ sbc.name }}</a>
|
|
69
|
+
|
|
70
|
+
<div class="page-header">
|
|
71
|
+
<h1>Console: {{ sbc.name }}</h1>
|
|
72
|
+
<p>Serial console connection</p>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="console-container">
|
|
76
|
+
<div class="console-header">
|
|
77
|
+
<div class="console-status">
|
|
78
|
+
<span class="status-dot" id="status-indicator"></span>
|
|
79
|
+
<span id="status-text">Disconnected</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div>
|
|
82
|
+
<button class="btn btn-success btn-sm" id="connect-btn" onclick="connectConsole()">Connect</button>
|
|
83
|
+
<button class="btn btn-danger btn-sm" id="disconnect-btn" onclick="disconnectConsole()" style="display: none;">Disconnect</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div id="terminal"></div>
|
|
88
|
+
|
|
89
|
+
<div class="console-footer">
|
|
90
|
+
<div class="console-info">
|
|
91
|
+
{% if port %}
|
|
92
|
+
Port: {{ port.device_path }} | TCP: {{ port.tcp_port }} | Baud: {{ port.baud_rate }}
|
|
93
|
+
{% else %}
|
|
94
|
+
No console port configured
|
|
95
|
+
{% endif %}
|
|
96
|
+
</div>
|
|
97
|
+
<div class="console-info">
|
|
98
|
+
<span id="write-status">Read-only</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="detail-card">
|
|
104
|
+
<h3>Keyboard Shortcuts</h3>
|
|
105
|
+
<table class="detail-table">
|
|
106
|
+
<tr><th>Ctrl+C</th><td>Send interrupt signal</td></tr>
|
|
107
|
+
<tr><th>Ctrl+D</th><td>Send EOF</td></tr>
|
|
108
|
+
<tr><th>Ctrl+L</th><td>Clear screen</td></tr>
|
|
109
|
+
<tr><th>Ctrl+A</th><td>Go to beginning of line</td></tr>
|
|
110
|
+
<tr><th>Ctrl+E</th><td>Go to end of line</td></tr>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
{% endblock %}
|
|
114
|
+
|
|
115
|
+
{% block scripts %}
|
|
116
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
117
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
118
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
|
|
119
|
+
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
|
120
|
+
<script>
|
|
121
|
+
const sbcName = "{{ sbc.name }}";
|
|
122
|
+
let terminal = null;
|
|
123
|
+
let fitAddon = null;
|
|
124
|
+
let socket = null;
|
|
125
|
+
let connected = false;
|
|
126
|
+
|
|
127
|
+
// Initialize terminal
|
|
128
|
+
function initTerminal() {
|
|
129
|
+
terminal = new Terminal({
|
|
130
|
+
cursorBlink: true,
|
|
131
|
+
fontSize: 14,
|
|
132
|
+
fontFamily: "'Monaco', 'Consolas', 'Courier New', monospace",
|
|
133
|
+
theme: {
|
|
134
|
+
background: '#1a1a2e',
|
|
135
|
+
foreground: '#eaeaea',
|
|
136
|
+
cursor: '#e94560',
|
|
137
|
+
selection: '#0f3460',
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
fitAddon = new FitAddon.FitAddon();
|
|
142
|
+
terminal.loadAddon(fitAddon);
|
|
143
|
+
terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
|
|
144
|
+
|
|
145
|
+
terminal.open(document.getElementById('terminal'));
|
|
146
|
+
fitAddon.fit();
|
|
147
|
+
|
|
148
|
+
// Handle window resize
|
|
149
|
+
window.addEventListener('resize', () => {
|
|
150
|
+
fitAddon.fit();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Handle terminal input
|
|
154
|
+
terminal.onData(data => {
|
|
155
|
+
if (connected && socket) {
|
|
156
|
+
socket.emit('console_input', { text: data });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
terminal.writeln('Lab Controller Console');
|
|
161
|
+
terminal.writeln('Click "Connect" to open console connection');
|
|
162
|
+
terminal.writeln('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function updateStatus(isConnected, message) {
|
|
166
|
+
const indicator = document.getElementById('status-indicator');
|
|
167
|
+
const statusText = document.getElementById('status-text');
|
|
168
|
+
const connectBtn = document.getElementById('connect-btn');
|
|
169
|
+
const disconnectBtn = document.getElementById('disconnect-btn');
|
|
170
|
+
|
|
171
|
+
connected = isConnected;
|
|
172
|
+
|
|
173
|
+
if (isConnected) {
|
|
174
|
+
indicator.className = 'status-dot connected';
|
|
175
|
+
statusText.textContent = message || 'Connected';
|
|
176
|
+
connectBtn.style.display = 'none';
|
|
177
|
+
disconnectBtn.style.display = 'inline-block';
|
|
178
|
+
} else {
|
|
179
|
+
indicator.className = 'status-dot disconnected';
|
|
180
|
+
statusText.textContent = message || 'Disconnected';
|
|
181
|
+
connectBtn.style.display = 'inline-block';
|
|
182
|
+
disconnectBtn.style.display = 'none';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function connectConsole() {
|
|
187
|
+
terminal.writeln('\x1b[33mConnecting...\x1b[0m');
|
|
188
|
+
|
|
189
|
+
socket = io('/console', {
|
|
190
|
+
transports: ['websocket']
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
socket.on('connect', () => {
|
|
194
|
+
console.log('WebSocket connected');
|
|
195
|
+
socket.emit('open_console', { sbc_name: sbcName });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
socket.on('console_opened', (data) => {
|
|
199
|
+
updateStatus(true, 'Connected');
|
|
200
|
+
terminal.writeln('\x1b[32m' + data.message + '\x1b[0m');
|
|
201
|
+
terminal.writeln('');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
socket.on('console_output', (data) => {
|
|
205
|
+
terminal.write(data.text);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
socket.on('error', (data) => {
|
|
209
|
+
terminal.writeln('\x1b[31mError: ' + data.message + '\x1b[0m');
|
|
210
|
+
updateStatus(false, 'Error');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
socket.on('disconnect', () => {
|
|
214
|
+
terminal.writeln('\x1b[31mDisconnected\x1b[0m');
|
|
215
|
+
updateStatus(false, 'Disconnected');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
socket.on('console_closed', () => {
|
|
219
|
+
terminal.writeln('\x1b[33mConsole closed\x1b[0m');
|
|
220
|
+
updateStatus(false, 'Closed');
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function disconnectConsole() {
|
|
225
|
+
if (socket) {
|
|
226
|
+
socket.emit('close_console');
|
|
227
|
+
socket.disconnect();
|
|
228
|
+
socket = null;
|
|
229
|
+
}
|
|
230
|
+
updateStatus(false, 'Disconnected');
|
|
231
|
+
terminal.writeln('\x1b[33mDisconnected\x1b[0m');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Initialize on page load
|
|
235
|
+
document.addEventListener('DOMContentLoaded', initTerminal);
|
|
236
|
+
</script>
|
|
237
|
+
{% endblock %}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Dashboard - Lab Controller{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="page-header">
|
|
7
|
+
<h1>SBC Dashboard</h1>
|
|
8
|
+
<p>{{ sbcs|length }} device(s) configured</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
{% if sbcs %}
|
|
12
|
+
<div class="sbc-grid">
|
|
13
|
+
{% for sbc in sbcs %}
|
|
14
|
+
<div class="sbc-card">
|
|
15
|
+
<div class="sbc-header">
|
|
16
|
+
<h2><a href="{{ url_for('views.sbc_detail', name=sbc.name) }}">{{ sbc.name }}</a></h2>
|
|
17
|
+
<span class="status-badge status-{{ sbc.status.value }}">{{ sbc.status.value }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="sbc-body">
|
|
21
|
+
<div class="sbc-info">
|
|
22
|
+
<div class="info-row">
|
|
23
|
+
<span class="label">Project:</span>
|
|
24
|
+
<span class="value">{{ sbc.project or '-' }}</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="info-row">
|
|
27
|
+
<span class="label">IP:</span>
|
|
28
|
+
<span class="value">{{ sbc.primary_ip or '-' }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="info-row">
|
|
31
|
+
<span class="label">Console:</span>
|
|
32
|
+
<span class="value">
|
|
33
|
+
{% if sbc.console_port %}
|
|
34
|
+
tcp:{{ sbc.console_port.tcp_port }}
|
|
35
|
+
{% else %}
|
|
36
|
+
-
|
|
37
|
+
{% endif %}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{% set claim = claims_by_sbc.get(sbc.name) %}
|
|
43
|
+
{% if claim %}
|
|
44
|
+
<div class="claim-badge">
|
|
45
|
+
<span class="claim-indicator"></span>
|
|
46
|
+
<span class="claim-holder">{{ claim.agent_name }}</span>
|
|
47
|
+
{% if claim.time_remaining %}
|
|
48
|
+
{% set rem = claim.time_remaining.total_seconds()|int %}
|
|
49
|
+
{% if rem >= 3600 %}
|
|
50
|
+
<span class="claim-time">{{ rem // 3600 }}h {{ (rem % 3600) // 60 }}m</span>
|
|
51
|
+
{% elif rem >= 60 %}
|
|
52
|
+
<span class="claim-time">{{ rem // 60 }}m</span>
|
|
53
|
+
{% else %}
|
|
54
|
+
<span class="claim-time">{{ rem }}s</span>
|
|
55
|
+
{% endif %}
|
|
56
|
+
{% endif %}
|
|
57
|
+
{% if claim.pending_requests %}
|
|
58
|
+
<span class="claim-warning" title="Release requested">!</span>
|
|
59
|
+
{% endif %}
|
|
60
|
+
</div>
|
|
61
|
+
{% endif %}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="sbc-footer">
|
|
65
|
+
{% if sbc.power_plug %}
|
|
66
|
+
{% set power = power_states.get(sbc.name) %}
|
|
67
|
+
<div class="power-status">
|
|
68
|
+
<span class="power-indicator power-{{ power.value if power else 'unknown' }}"></span>
|
|
69
|
+
<span>{{ power.value.upper() if power else 'UNKNOWN' }}</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="power-actions">
|
|
72
|
+
<form action="{{ url_for('views.sbc_power_action', name=sbc.name, action='on') }}" method="post" class="inline-form">
|
|
73
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
74
|
+
<button type="submit" class="btn btn-sm btn-success" title="Power On">ON</button>
|
|
75
|
+
</form>
|
|
76
|
+
<form action="{{ url_for('views.sbc_power_action', name=sbc.name, action='off') }}" method="post" class="inline-form">
|
|
77
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
78
|
+
<button type="submit" class="btn btn-sm btn-danger" title="Power Off">OFF</button>
|
|
79
|
+
</form>
|
|
80
|
+
<form action="{{ url_for('views.sbc_power_action', name=sbc.name, action='cycle') }}" method="post" class="inline-form">
|
|
81
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
82
|
+
<button type="submit" class="btn btn-sm btn-warning" title="Power Cycle">⟳</button>
|
|
83
|
+
</form>
|
|
84
|
+
</div>
|
|
85
|
+
{% else %}
|
|
86
|
+
<div class="power-status">
|
|
87
|
+
<span class="text-muted">No power plug</span>
|
|
88
|
+
</div>
|
|
89
|
+
{% endif %}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
{% endfor %}
|
|
93
|
+
</div>
|
|
94
|
+
{% else %}
|
|
95
|
+
<div class="empty-state">
|
|
96
|
+
<h2>No SBCs configured</h2>
|
|
97
|
+
<p>Use <code>labctl add <name></code> to add an SBC.</p>
|
|
98
|
+
</div>
|
|
99
|
+
{% endif %}
|
|
100
|
+
{% endblock %}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Login - Lab Controller{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="login-container">
|
|
7
|
+
<div class="login-card">
|
|
8
|
+
<h1>Lab Controller</h1>
|
|
9
|
+
<p class="text-muted">Sign in to continue</p>
|
|
10
|
+
|
|
11
|
+
<form method="post" class="login-form">
|
|
12
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
13
|
+
<div class="form-group">
|
|
14
|
+
<label for="username">Username</label>
|
|
15
|
+
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="form-group">
|
|
18
|
+
<label for="password">Password</label>
|
|
19
|
+
<input type="password" id="password" name="password" class="form-control" required>
|
|
20
|
+
</div>
|
|
21
|
+
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
|
|
22
|
+
</form>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
{% endblock %}
|