Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from app.auth_store import encrypt_secret
from app.auth_store import decrypt_secret
from app.db import get_db
from app.device_update import can_start_update
from app.device_update import update_available
from app.resolution import connected_resolution_from_web_ui
from app.resolution import resolution_from_automation_state
from app.snapshot_service import write_latest_screenshot
Expand Down Expand Up @@ -357,13 +359,15 @@ def get_device_snapshot(device_id: int):

client = TinyPilotClient(row['base_url'], api_key=api_key)

# API-key allowlist (Pro 3.2.0+): version, network, video, /state, screenshot.
# Skip Web UI session/CSRF-only routes (auth, requiresHttps, virtual media).
# API-key allowlist (Pro 3.2.0+): version, network, video, /state, screenshot,
# latestRelease, update status/start.
status, status_error = _safe_fetch(client.get_status)
version, version_error = _safe_fetch(client.get_version)
network, network_error = _safe_fetch(client.get_network_status)
video, video_error = _safe_fetch(client.get_video_settings)
automation_state, automation_state_error = _safe_fetch(client.get_automation_state)
latest_release, latest_release_error = _safe_fetch(client.get_latest_release)
update_job, update_job_error = _safe_fetch(client.get_update_status)

last_error = (
status_error
Expand All @@ -389,11 +393,15 @@ def get_device_snapshot(device_id: int):
or connected_resolution_from_web_ui(video, status)
)

current_version = (version or {}).get('version')
latest_version = (latest_release or {}).get('version')
license_check_status = (latest_release or {}).get('licenseCheckStatus')

collapsed = {
'friendly_name': row['friendly_name'],
'device_url': row['base_url'],
'online': online,
'software_version': (version or {}).get('version', 'unknown'),
'software_version': current_version or 'unknown',
'last_checked': datetime.now(timezone.utc).isoformat(),
'api_key_status': 'configured',
}
Expand All @@ -406,6 +414,16 @@ def get_device_snapshot(device_id: int):
'automation_state': {'status': automation_state, 'error': automation_state_error},
'connected_device_resolution': connected_resolution,
'last_management_error': last_error,
'software_update': {
'latest': latest_release,
'latest_error': latest_release_error,
'job': update_job,
'job_error': update_job_error,
'update_available': update_available(current_version, latest_version),
'can_start': can_start_update(
current_version, latest_version, license_check_status
),
},
}

return jsonify(
Expand All @@ -416,3 +434,31 @@ def get_device_snapshot(device_id: int):
'expanded': expanded,
}
)


@api_blueprint.get('/devices/<int:device_id>/device/update')
def get_device_update_status(device_id: int):
client, err = _client_with_api_key(device_id)
if err is not None:
return err
try:
result = client.get_update_status()
except Exception as exc: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to fetch update status: {exc}'}), 502
return jsonify(result)


@api_blueprint.put('/devices/<int:device_id>/device/update')
def start_device_update(device_id: int):
client, err = _client_with_api_key(device_id)
if err is not None:
return err
payload = request.get_json(silent=True) or {}
version = (payload.get('version') or '').strip()
if not version:
return jsonify({'error': 'version is required'}), 400
try:
client.start_update(version)
except Exception as exc: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to start update: {exc}'}), 502
return jsonify({'device_id': device_id, 'update_started': True, 'version': version})
24 changes: 24 additions & 0 deletions app/device_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Helpers for TinyPilot device software-update banners."""


def license_blocks_update(license_check_status: str | None) -> bool:
"""Return True when Gatekeeper/Web UI would refuse an update kickoff."""
if not license_check_status:
return True
return license_check_status != 'VALID'


def update_available(current_version: str | None, latest_version: str | None) -> bool:
if not current_version or not latest_version:
return False
return current_version != latest_version


def can_start_update(
current_version: str | None,
latest_version: str | None,
license_check_status: str | None,
) -> bool:
return update_available(current_version, latest_version) and not license_blocks_update(
license_check_status
)
26 changes: 26 additions & 0 deletions app/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -711,3 +711,29 @@ button:active {
padding: 0 1rem;
}
}

.device-update-banner {
margin-top: var(--space-3);
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}

.device-update-banner__text {
margin: 0;
font-size: 0.9rem;
line-height: 1.4;
}

.device-update-banner__actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-2);
}

.device-update-banner__error {
display: block;
margin-top: var(--space-1);
color: var(--color-danger, #b42318);
}
5 changes: 5 additions & 0 deletions app/static/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ async function getJson(path) {
return fetchJson(path);
}

async function putJson(path, body = {}) {
return fetchJson(path, { method: 'PUT', body });
}

async function deleteJson(path) {
return fetchJson(path, { method: 'DELETE' });
}
Expand All @@ -43,4 +47,5 @@ window.dashboardApi = {
deleteJson,
getJson,
postJson,
putJson,
};
150 changes: 146 additions & 4 deletions app/static/js/components/device-card.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { escapeHtml, formatRelativeTime } from '../lib/strings.js';
import { formatExpandedSnapshot } from '../lib/snapshot-view.js';
import {
latestVersionFrom,
licenseReason,
licenseStatusFrom,
updateBannerKind,
} from '../lib/device-update.js';

const AUTOMATION_LICENSE_INFO_URL = 'https://tinypilotkvm.com/pages/automation';
const UPDATE_POLL_INTERVAL_MS = 3000;

function formatCollapsedSnapshotSummary(c) {
const checkedAt = c.last_checked || '';
Expand Down Expand Up @@ -30,6 +37,8 @@ class DeviceCard extends HTMLElement {
super();
this._device = null;
this._elements = {};
this._updatePollTimer = null;
this._desiredUpdateVersion = null;
}

set device(value) {
Expand All @@ -49,6 +58,10 @@ class DeviceCard extends HTMLElement {
}
}

disconnectedCallback() {
this._stopUpdatePoll();
}

_cacheElements() {
const id = this._device.id;
this._elements = {
Expand All @@ -58,6 +71,7 @@ class DeviceCard extends HTMLElement {
summaryOutput: this.querySelector(`#device-collapsed-summary-${id}`),
metricsOutput: this.querySelector(`#device-metrics-output-${id}`),
intervalInput: this.querySelector(`#screenshot-interval-${id}`),
updateBanner: this.querySelector(`#device-update-banner-${id}`),
};
}

Expand Down Expand Up @@ -129,6 +143,7 @@ class DeviceCard extends HTMLElement {
<div id="device-collapsed-summary-${id}" class="metrics-output">
Retrieving device snapshot...
</div>
<div id="device-update-banner-${id}" class="device-update-banner" hidden></div>
<details class="device-details">
<summary>Expanded TinyPilot device info</summary>
<div id="device-metrics-output-${id}" class="metrics-output">Retrieving expanded device info...</div>
Expand Down Expand Up @@ -218,14 +233,141 @@ class DeviceCard extends HTMLElement {
summaryOutput.innerHTML = formatCollapsedSnapshotSummary(snapshot.collapsed);
}
}
if (!metricsOutput) {
if (metricsOutput) {
if (snapshot.error) {
metricsOutput.textContent = snapshot.error;
} else {
metricsOutput.innerHTML = formatExpandedSnapshot(snapshot);
}
}
if (!snapshot.error) {
this._renderUpdateBanner(snapshot);
}
}

_stopUpdatePoll() {
if (this._updatePollTimer) {
window.clearInterval(this._updatePollTimer);
this._updatePollTimer = null;
}
}

_renderUpdateBanner(snapshot) {
const banner = this._elements.updateBanner;
if (!banner || !this._device) {
return;
}
const id = this._device.id;
const currentVersion = snapshot.collapsed?.software_version || '';
const softwareUpdate = snapshot.expanded?.software_update || null;
const kind = updateBannerKind(softwareUpdate, currentVersion);
const latestVersion = latestVersionFrom(softwareUpdate);
const licenseStatus = licenseStatusFrom(softwareUpdate);
const jobError = softwareUpdate?.job?.updateError;

if (!kind || kind === 'hidden') {
banner.hidden = true;
banner.innerHTML = '';
this._stopUpdatePoll();
return;
}

banner.hidden = false;
if (kind === 'updating') {
banner.innerHTML = `
<p class="device-update-banner__text">
Updating TinyPilot${latestVersion ? ` to ${escapeHtml(latestVersion)}` : ''}…
${jobError ? `<span class="device-update-banner__error">${escapeHtml(jobError)}</span>` : ''}
</p>
`;
this._startUpdatePoll();
return;
}
if (kind === 'error') {
banner.innerHTML = `
<p class="device-update-banner__text">
Could not check for updates:
${escapeHtml(softwareUpdate.latest_error || 'unknown error')}
</p>
`;
this._stopUpdatePoll();
return;
}
if (kind === 'license-blocked') {
banner.innerHTML = `
<p class="device-update-banner__text">
Update available (${escapeHtml(currentVersion)} → ${escapeHtml(latestVersion)}),
but ${escapeHtml(licenseReason(licenseStatus))}.
<a class="launch-link" href="${escapeHtml(this._device.base_url)}" target="_blank" rel="noopener noreferrer">Attach license in WebUI ↗</a>
</p>
`;
this._stopUpdatePoll();
return;
}
// available
this._desiredUpdateVersion = latestVersion;
banner.innerHTML = `
<p class="device-update-banner__text">
Update available: ${escapeHtml(currentVersion)} → ${escapeHtml(latestVersion)}
</p>
<div class="device-update-banner__actions">
<button type="button" data-action="start-device-update" data-device-id="${id}">Update</button>
</div>
`;
this._stopUpdatePoll();
}

_startUpdatePoll() {
if (this._updatePollTimer || !this._device) {
return;
}
const id = this._device.id;
this._updatePollTimer = window.setInterval(async () => {
const status = await window.dashboardApi.getJson(`/api/devices/${id}/device/update`);
if (status.error) {
return;
}
if (status.status === 'IN_PROGRESS') {
const banner = this._elements.updateBanner;
if (banner && !banner.hidden) {
const err = status.updateError
? `<span class="device-update-banner__error">${escapeHtml(status.updateError)}</span>`
: '';
banner.innerHTML = `
<p class="device-update-banner__text">Updating TinyPilot… ${err}</p>
`;
}
return;
}
this._stopUpdatePoll();
await this.refreshSnapshot();
}, UPDATE_POLL_INTERVAL_MS);
}

async startUpdate() {
if (!this._device || !this._desiredUpdateVersion) {
return;
}
if (snapshot.error) {
metricsOutput.textContent = snapshot.error;
const id = this._device.id;
const banner = this._elements.updateBanner;
if (banner) {
banner.hidden = false;
banner.innerHTML = `<p class="device-update-banner__text">Starting update…</p>`;
}
const result = await window.dashboardApi.putJson(`/api/devices/${id}/device/update`, {
version: this._desiredUpdateVersion,
});
if (result.error) {
if (banner) {
banner.innerHTML = `
<p class="device-update-banner__text device-update-banner__error">
${escapeHtml(result.error)}
</p>
`;
}
return;
}
metricsOutput.innerHTML = formatExpandedSnapshot(snapshot);
this._startUpdatePoll();
}

}
Expand Down
4 changes: 4 additions & 0 deletions app/static/js/dashboard-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ class DashboardApp extends HTMLElement {
}
if (action === 'fetch-device-snapshot') {
await card.refreshSnapshot();
return;
}
if (action === 'start-device-update') {
await card.startUpdate();
}
}

Expand Down
Loading
Loading