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
15 changes: 15 additions & 0 deletions .github/workflows/shellcheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: ShellCheck

on:
push:
pull_request:

jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
with:
scandir: './deploy'
67 changes: 64 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,66 @@ A self-hosted dashboard for monitoring and controlling multiple TinyPilot device
- A [TinyPilot Automation License](https://tinypilotkvm.com/pages/automation) on each device — required for the live and on-demand screenshots the dashboard displays.
- A modern browser (Chrome, Firefox, Safari, or Edge).

## Install
## Deploy on DigitalOcean

For always-on access from anywhere on your Tailscale network, run the
dashboard on a DigitalOcean droplet. All droplet sizes run identical
software — choose based on your workload:

| Use case | Size | Cost |
|---|---|---|
| Dashboard only, light use | `s-1vcpu-1gb` | $6/mo |
| Dashboard only, comfortable | `s-1vcpu-2gb` | $12/mo |
| Dashboard + heavier polling | `s-2vcpu-4gb` | $24/mo |
| Dashboard + AI agent (Cursor/Claude Code) | `s-4vcpu-8gb` | $48/mo |

Droplets can be resized in the DigitalOcean control panel at any time
without data loss.

### Steps

1. Make sure you have an [SSH key added to your DigitalOcean account](https://cloud.digitalocean.com/account/security).
DigitalOcean requires one at droplet creation — you won't need to use it
day-to-day once Tailscale is running.
2. [Create a DigitalOcean droplet](https://cloud.digitalocean.com/droplets/new)
running **Ubuntu 24.04 LTS**.
3. In **Additional Options → Startup script**, paste the contents of
[`deploy/cloud-init.sh`](deploy/cloud-init.sh) from this repository.
4. At the top of the pasted script, set your Tailscale auth key
(see below). Leave it blank to skip Tailscale.
5. Click **Create Droplet**. The dashboard installs automatically on
first boot. Allow about 10 minutes (faster on larger droplets), then
open `https://<machine-name>.<tailnet>.ts.net` in your browser — that
is your dashboard URL. (The machine name comes from the droplet name
you set in step 2.)

### Tailscale (strongly recommended)

[Generate a reusable auth key](https://login.tailscale.com/admin/settings/keys)
in your Tailscale admin console, then set it at the top of the
cloud-init script:

```
TAILSCALE_AUTH_KEY="tskey-auth-xxxxxxxx"
```

Once setup completes, the dashboard is available at
`https://<machine-name>.<tailnet>.ts.net` — accessible only to devices
on your Tailscale network, with HTTPS provided automatically.

**Without Tailscale:** the dashboard is localhost-only. Access it via
SSH tunnel (`ssh -L 8080:localhost:8080 root@<droplet-ip>`) and open
`http://localhost:8080`. For additional server hardening, see
DigitalOcean's [Initial Server Setup guide](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu).

### Updates

The droplet auto-updates the dashboard daily at 3am. No action required.

## Run locally

For running on a machine that is already on the same network as your
TinyPilot devices:

1. Clone or download this repository.
2. From the repository directory, start the dashboard:
Expand All @@ -39,7 +98,9 @@ To stop the dashboard:
docker compose down
```

Compose binds to `127.0.0.1:8080` on the host by default, so the UI is reachable only from the machine running it. To expose it to other hosts on your trusted network, see [Deployment & security](#deployment--security).
Compose binds to `127.0.0.1:8080` on the host by default, so the UI is
reachable only from the machine running it. To expose it to other hosts
on your trusted network, see [Deployment & security](#deployment--security).

## Adding a device

Expand All @@ -51,7 +112,7 @@ Compose binds to `127.0.0.1:8080` on the host by default, so the UI is reachable

## Deployment & security

The dashboard is designed to live **inside a network perimeter you already trust**. It does not authenticate inbound users and does not implement CSRF protection on its API; the deployment boundary *is* the security boundary.
The dashboard is designed to live **inside a network perimeter you already trust**. It does not authenticate inbound users — there are no login sessions or cookies — so standard CSRF attacks (which require a session to hijack) do not apply to this release. State-changing API requests require `Content-Type: application/json`, which triggers a browser CORS preflight that blocks cross-origin requests. If HTTP authentication is added in a future release, CSRF token protection will be required alongside it.

### Deploy behind a trusted perimeter — pick one

Expand Down
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# 0.5.x -> beta
# 1.0.0 -> first stable release
# Bump this on every customer-visible release.
__version__ = '0.1.0'
__version__ = '0.1.1'


def create_app(test_config=None):
Expand Down
98 changes: 75 additions & 23 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,28 +438,6 @@ def refresh_csrf_token(device_id: int):
return jsonify({'device_id': device_id, 'csrf_refreshed': True})


@api_blueprint.get('/devices/<int:device_id>/device/metrics')
def get_device_metrics(device_id: int):
row = _device_row_with_web_auth(device_id)
if row is None:
return jsonify({'error': 'device not found'}), 404

key_path = Path(current_app.config['SECRET_KEY_PATH'])
http_basic = _http_basic_from_auth_row(key_path, row)
client = TinyPilotClient(row['base_url'], http_basic=http_basic)
try:
client.refresh_csrf_token()
metrics = client.get_network_status()
except Exception as err: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to fetch device metrics: {err}'}), 502
return jsonify(
{
'device_id': device_id,
'source_base_url': row['base_url'],
'metrics': metrics,
}
)


def _safe_fetch(fetcher):
try:
Expand Down Expand Up @@ -561,7 +539,7 @@ def get_device_snapshot(device_id: int):
'reachability': {'status': status, 'error': status_error},
'web_session': {'status': auth_status, 'error': auth_error},
'version': {'status': version, 'error': version_error},
'network': {'interfaces': (network or {}).get('interfaces', []), 'error': network_error},
'network': {'data': network or {}, 'error': network_error},
'https_requirement': {'status': requires_https, 'error': https_error},
'video_settings': {'status': video, 'error': video_error},
'connected_device_resolution': connected_resolution,
Expand All @@ -576,3 +554,77 @@ def get_device_snapshot(device_id: int):
'expanded': expanded,
}
)


@api_blueprint.get('/devices/<int:device_id>/media')
def get_device_media(device_id: int):
"""Return current virtual media state (backing files + mount mode) for a device."""
row = _device_row_with_web_auth(device_id)
if row is None:
return jsonify({'error': 'device not found'}), 404
key_path = Path(current_app.config['SECRET_KEY_PATH'])
http_basic = _http_basic_from_auth_row(key_path, row)
client = TinyPilotClient(row['base_url'], http_basic=http_basic)
try:
result = client.get_mass_storage()
except Exception as err: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to fetch media state: {err}'}), 502
return jsonify(result)


@api_blueprint.post('/devices/<int:device_id>/media/fetch')
def fetch_device_media_from_url(device_id: int):
"""Tell the device to download an image from a URL."""
row = _device_row_with_web_auth(device_id)
if row is None:
return jsonify({'error': 'device not found'}), 404
payload = request.get_json(silent=True) or {}
url = (payload.get('url') or '').strip()
if not url:
return jsonify({'error': 'url is required'}), 400
key_path = Path(current_app.config['SECRET_KEY_PATH'])
http_basic = _http_basic_from_auth_row(key_path, row)
client = TinyPilotClient(row['base_url'], http_basic=http_basic)
try:
file_name = client.get_mass_storage_filename_from_url(url)
client.fetch_mass_storage_from_url(file_name, url)
except Exception as err: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to fetch image from URL: {err}'}), 502
return jsonify({'device_id': device_id, 'fileName': file_name})


@api_blueprint.put('/devices/<int:device_id>/media/mount')
def mount_device_media(device_id: int):
"""Mount a backing file on the device."""
row = _device_row_with_web_auth(device_id)
if row is None:
return jsonify({'error': 'device not found'}), 404
payload = request.get_json(silent=True) or {}
file_name = (payload.get('fileName') or '').strip()
mode = (payload.get('mode') or '').strip()
if not file_name or not mode:
return jsonify({'error': 'fileName and mode are required'}), 400
key_path = Path(current_app.config['SECRET_KEY_PATH'])
http_basic = _http_basic_from_auth_row(key_path, row)
client = TinyPilotClient(row['base_url'], http_basic=http_basic)
try:
client.mount_mass_storage(file_name, mode)
except Exception as err: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to mount image: {err}'}), 502
return jsonify({'device_id': device_id, 'mounted': True, 'fileName': file_name, 'mode': mode})


@api_blueprint.put('/devices/<int:device_id>/media/eject')
def eject_device_media(device_id: int):
"""Eject the currently mounted image on the device."""
row = _device_row_with_web_auth(device_id)
if row is None:
return jsonify({'error': 'device not found'}), 404
key_path = Path(current_app.config['SECRET_KEY_PATH'])
http_basic = _http_basic_from_auth_row(key_path, row)
client = TinyPilotClient(row['base_url'], http_basic=http_basic)
try:
client.eject_mass_storage()
except Exception as err: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to eject media: {err}'}), 502
return jsonify({'device_id': device_id, 'ejected': True})
140 changes: 140 additions & 0 deletions app/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -701,3 +701,143 @@ button:active {
padding: 0 1rem;
}
}

/* Virtual media section */

.virtual-media-details {
border-top: 1px solid color-mix(in srgb, var(--color-text) 10%, transparent);
padding-top: var(--space-3);
margin-top: var(--space-2);
}

.virtual-media-summary {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
list-style: none;
cursor: pointer;
user-select: none;
padding: 0;
}

.virtual-media-summary::-webkit-details-marker {
display: none;
}

.virtual-media-summary__lead {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}

.virtual-media-summary__heading {
margin: 0;
font-size: 1.0625rem;
line-height: 1.3;
font-weight: 600;
color: var(--color-text);
}

.virtual-media-summary-text {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
}

.virtual-media-summary__toggle {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 2rem;
min-height: 2.25rem;
flex-shrink: 0;
font-size: 1.35rem;
font-weight: 600;
line-height: 1;
color: var(--color-accent);
user-select: none;
}

.virtual-media-details[open] .virtual-media-summary__toggle::after {
content: '\2212';
}

.virtual-media-details:not([open]) .virtual-media-summary__toggle::after {
content: '+';
}

.virtual-media-summary:hover .virtual-media-summary__toggle,
.virtual-media-summary:focus-visible .virtual-media-summary__toggle {
color: var(--color-accent-hover);
}

.virtual-media-body {
margin-top: var(--space-3);
}

.virtual-media-select,
.virtual-media-url-input {
display: block;
width: 100%;
margin-bottom: var(--space-2);
font-family: inherit;
font-size: 0.9rem;
box-sizing: border-box;
}

.virtual-media-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
margin-top: var(--space-1);
}

.virtual-media-confirm-text {
font-size: 0.88rem;
margin-right: auto;
}

.virtual-media-info {
display: grid;
grid-template-columns: 5rem 1fr;
gap: var(--space-1) var(--space-3);
margin: 0 0 var(--space-3) 0;
font-size: 0.88rem;
}

.virtual-media-info dt {
color: var(--color-text-muted, #5a6770);
}

.virtual-media-info dd {
margin: 0;
word-break: break-all;
}

.virtual-media-hint {
margin: var(--space-1) 0 0 0;
font-size: 0.9rem;
}

.virtual-media-hint a {
color: var(--color-accent);
text-decoration: none;
font-weight: 600;
}

.virtual-media-hint a:hover,
.virtual-media-hint a:focus-visible {
color: var(--color-accent-hover);
text-decoration: none;
}

.virtual-media-status {
font-size: 0.82rem;
color: var(--color-text-muted, #5a6770);
min-height: 1.2em;
margin: var(--space-2) 0 0 0;
}
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,
};
Loading
Loading