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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ __pycache__/
*.env
!*.example.env
*.secret
*.env.before-fix-*
*.sqlite
*.sqlite3
*.sqlite-wal
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@
All notable changes to VesselStack are documented here. The project follows
Semantic Versioning.

## 0.6.1 - 2026-11-02

### Highlights

- Upgrades and installs now offer to fix an unsupported units setting or an unsafe saved value at the prompt, instead of stopping; unattended runs still stop safely and print the exact fix. Each fix keeps a root-only `*.before-fix-*` copy of the previous settings file, which you can delete once the install works.
- The Control Panel shows the exact fix when an update or backup stops for something you can fix (for example an unmounted backup drive).
- When the backup drive isn't mounted, updates and backups offer to mount it, pick a connected USB drive, check again, or back up to the system disk once, instead of stopping.

### Fixes

- The backup-drive menu now lists a USB drive that has no label (its columns were shifted, so the wrong value reached `mount`), accepts only exactly one listed answer (`1 2` is asked again, never guessed), and is not offered drives that `/etc/fstab` assigns to another mount point. An empty list says how to unmount a drive your desktop already mounted.
- Fresh installs started with `--yes`, `--defaults` or scripted answers no longer let the installer ask questions.
- A drive mounted from the menu is checked before anything is written: its top folder must be owned by root and not writable by others, and no folder on the way to the backup folder may be a symbolic link or belong to another user.
- The prompt library, its parent folders and the folder of a settings file being fixed must be root-controlled; the scripted-answers test hook (`VESSELSTACK_PROMPT_ANSWERS`) is read only from a private file you own. `vesselstackctl uninstall` also removes the prompt library.

- Upgrading a boat whose settings say `BOAT_UNITS=nautical` (offered by older setup wizards) no
longer stops the update; it is read as metric, and knots and nautical miles are kept.
- A clearer message, naming the file and the fix, for any other unsupported `BOAT_UNITS` value.

### Release status

`v0.6.1` is an SSH-signed tag verified by the release workflow against `.github/allowed_signers`.
It is published first as a pre-release and marked Latest after the maintainer's physical
Raspberry Pi install passes.

## 0.6.0 - 2026-11-02

### Highlights
Expand Down
187 changes: 159 additions & 28 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.0
0.6.1
2 changes: 1 addition & 1 deletion boat-chat/display_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@


def normalize_preference(value: str | None) -> str:
return "metric" if str(value or "").strip().lower() in {"metric", "si", "metre", "meters"} else "us_customary"
return "metric" if str(value or "").strip().lower() in {"metric", "si", "metre", "meters", "nautical"} else "us_customary"


def preference(setting_getter: Callable[[str, str], str] | None = None) -> str:
Expand Down
21 changes: 18 additions & 3 deletions control-panel/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ def public_configuration() -> dict[str, Any]:
for spec in FIELDS:
source = chat if spec["source"] == "chat" else vessel
value = source.get(spec["key"], "")
if spec["key"] == "BOAT_UNITS" and value.strip().lower() == "nautical":
value = "metric" # legacy value from older setup wizards: show Metric, not an unknown choice
if spec["key"] in {"CONTROL_PANEL_HOST", "CONTROL_PANEL_PORT"}:
value = panel.get(spec["key"], value)
result[spec["key"]] = {"configured": bool(value)} if spec["kind"] == "secret" else value
Expand Down Expand Up @@ -882,6 +884,9 @@ def backup_status(vessel: dict[str, str] | None = None) -> dict[str, Any]:
ERROR_LINE = re.compile(r"^(?:\[[^\]]*\]\s*)?(?:ERROR|Error|error|FATAL|Refusing[^:]*|Backup[^:]*failed)[:\s]+(.*)$")


FIX_PREFIX = "To fix:"


def summarize_operation(operation: dict[str, Any]) -> dict[str, Any]:
"""Plain facts about a finished operation, read from its output only."""
lines = [str(line).strip() for line in operation.get("output") or [] if str(line).strip()]
Expand All @@ -893,12 +898,21 @@ def summarize_operation(operation: dict[str, Any]) -> dict[str, Any]:
break
if operation.get("state") == "failed" and lines:
reason = lines[-1]
for line in reversed(lines):
found = ERROR_LINE.match(line)
matched = None
for index in range(len(lines) - 1, -1, -1):
found = ERROR_LINE.match(lines[index])
if found:
reason = found.group(1).strip() or line
reason = found.group(1).strip() or lines[index]
matched = index
break
summary["reason"] = reason[:400]
# The scripts print the exact remedy as "To fix: ..." right after a refusal; these lines never carry secrets.
for line in lines[(matched + 1 if matched is not None else 0):]:
if line.startswith(FIX_PREFIX):
fix = line[len(FIX_PREFIX):].strip()
if fix:
summary["fix"] = fix[:600]
break
return summary


Expand Down Expand Up @@ -955,6 +969,7 @@ def start(self, label: str, command_or_factory: list[str] | Any) -> dict[str, An
def _run(self, operation: dict[str, Any], command: list[str], slot_fd) -> None:
environment = os.environ.copy()
environment["VESSELSTACK_SKIP_PANEL_RESTART"] = "true"
environment["VESSELSTACK_NONINTERACTIVE"] = "true" # the panel cannot answer prompts; scripts print "To fix:" guidance instead
exit_code = 1
succeeded = False
try:
Expand Down
2 changes: 1 addition & 1 deletion control-panel/browser_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ def run_update(request: dict[str, Any], operation: dict[str, Any], root: Path) -
if not installer.is_file():
raise ValueError("release installer missing")

child_env = dict(os.environ, VESSELSTACK_ROOT=str(root), VESSELSTACK_ENV=config)
child_env = dict(os.environ, VESSELSTACK_ROOT=str(root), VESSELSTACK_ENV=config, VESSELSTACK_NONINTERACTIVE="true")
_validate_fixed_ctl()
operation.update(phase="preflight"); write_operation(root, operation)
run_command([str(installer), "--config", config, "--dry-run"], root, operation, 1800, child_env)
Expand Down
8 changes: 6 additions & 2 deletions control-panel/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,12 @@ function renderBanner(operation, context) {
html = `<div class="banner ok" role="status"><span class="icon" aria-hidden="true">&#10003;</span><div class="msg"><strong>${escapeHtml(text.done)}${escapeHtml(when)}</strong>${tail}${log("Details")}</div><button type="button" class="x" id="banner-x" aria-label="Dismiss message">&#10005;</button></div>`;
} else {
const reason = String(summary.reason || "see the details below").replace(/[.\s]+$/, "");
const next = /mount/i.test(reason) && operation.label === "backup" ? "Plug in or mount the backup drive and try again." : text.next;
html = `<div class="banner fail" role="alert"><span class="icon" aria-hidden="true">!</span><div class="msg"><strong>${escapeHtml(text.failed)}</strong> — ${escapeHtml(reason)}. ${escapeHtml(next)}${log("Show details")}</div><button type="button" class="x" id="banner-x" aria-label="Dismiss message">&#10005;</button></div>`;
const fix = String(summary.fix || "").trim();
const next = fix ? "" : /mount/i.test(reason) && operation.label === "backup" ? "Plug in or mount the backup drive and try again." : text.next;
// The exact remedy the script printed; a trailing "sudo ..." command is set in monospace. Everything is escaped.
const split = fix.match(/^(.*?)(\bsudo\s.*)$/s);
const fixHtml = fix ? `<div class="fix"><strong>To fix:</strong> ${split ? `${escapeHtml(split[1])}<span class="mono">${escapeHtml(split[2])}</span>` : escapeHtml(fix)}</div>` : "";
html = `<div class="banner fail" role="alert"><span class="icon" aria-hidden="true">!</span><div class="msg"><strong>${escapeHtml(text.failed)}</strong> — ${escapeHtml(reason)}.${next ? ` ${escapeHtml(next)}` : ""}${fixHtml}${log("Show details")}</div><button type="button" class="x" id="banner-x" aria-label="Dismiss message">&#10005;</button></div>`;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions control-panel/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ main{width:min(1180px,calc(100% - 2rem));margin:0 auto;padding:0 0 6rem}
.banner.info{color:var(--sea);background:var(--sea-soft);border-color:var(--sea)}
.banner .msg{flex:1;min-width:0;color:var(--ink)}
.banner .msg strong{color:inherit}
.banner .fix{margin-top:.5rem;overflow-wrap:anywhere}
.banner .fix .mono{font-size:.88rem}
.banner .icon{font-weight:800;font-size:1.1rem;line-height:1.5}
.banner details{margin-top:.5rem}
.banner summary,details.disc>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600;color:var(--sea)}
Expand Down
9 changes: 7 additions & 2 deletions docs/CONTROL_PANEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,13 @@ disabled so lifecycle changes cannot overlap.

The result of the latest operation stays in a banner under the tabs until you
dismiss it. Success shows the time and, for a backup, where the file was saved;
failure shows the reason and what to try next. **Details** opens the command
output.
failure shows the reason and what to try next. When an update or backup stops
for something you can fix (for example an unmounted backup drive, an unsupported
`BOAT_UNITS` value, or a saved value that cannot be written safely), the banner
adds a **To fix:** line with the exact step, and any command to run over SSH is
shown in monospace. The Control Panel never asks questions or changes your
settings itself; run the fix, then start the operation again. **Details** opens
the command output.

![Control Panel Home with a green banner reporting that a backup finished and where it was saved](images/control-panel-backup-result.png)

Expand Down
29 changes: 20 additions & 9 deletions docs/CONTROL_PANEL_TESTING.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
# Control Panel component lifecycle lab test

This is the release 0.6.0 lab recipe. It is a bounded acceptance test, not
This is the release 0.6.1 lab recipe. It is a bounded acceptance test, not
evidence of a boat or physical Raspberry Pi test. Use a scratch lab Pi or an
isolated VM with Docker and a mounted data disk. Keep real vessel identity,
positions, MMSIs, tokens, logs, and backups out of this repository.

## Fetch and verify the pinned prerelease

Download the pinned bootstrap and the CI-generated checksum from the same
`v0.6.0` GitHub release. Verify before reading or running it:
`v0.6.1` GitHub release. Verify before reading or running it:

```bash
curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh
curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256
curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.1/get-vesselstack.sh
curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.1/get-vesselstack.sh.sha256
sha256sum -c get-vesselstack.sh.sha256
less get-vesselstack.sh
sudo bash get-vesselstack.sh --help
```

0.6.0 release status: `v0.6.0` is an SSH-signed tag, published first as a pre-release and
0.6.1 release status: `v0.6.1` is an SSH-signed tag, published first as a pre-release and
marked Latest after the maintainer's physical Raspberry Pi install passes (see the CHANGELOG).
Check the tag with `scripts/verify-release-tag.sh v0.6.0`. The checksum catches a corrupt or
Check the tag with `scripts/verify-release-tag.sh v0.6.1`. The checksum catches a corrupt or
truncated asset; it does not replace that signature check.

## Exercise the browser updater

Open the authenticated panel's **Updates** tab and press **Check for updates**. Before
you press it, confirm the tab says nothing has been contacted. Confirm the catalog
is `jclima/VesselStack`, stable releases are the default, and **Include early test
releases** is opt-in. When the installed `0.6.0` release is current, the panel
releases** is opt-in. When the installed `0.6.1` release is current, the panel
correctly reports "You're up to date"; do not downgrade, retag, or force the
same version. Exercise **Back up & install** only when a newer public release
is available. A fresh `0.6.0` install is a CLI install; an existing `0.5.5`,
is available. A fresh `0.6.1` install is a CLI install; an existing `0.5.5`,
`0.5.6` or `0.5.7` install may use the panel upgrade path, after enabling
**Include early test releases** (named **Include lab prereleases** in the 0.5.x panel) if
0.6.0 is published as a prerelease. CI-controlled fixtures exercise the install
0.6.1 is published as a prerelease. CI-controlled fixtures exercise the install
orchestration, and the actual systemd restart is covered by the backend
verifier. Full physical-Pi upgrade proof remains pending physical testing.
Mount the configured external backup device first. Confirm the dialog and the Home tab
Expand Down Expand Up @@ -82,6 +82,17 @@ Use the real backup mount path in place of `/media/vesselstack-backup`. A
backup directory on the same system disk does not protect against disk failure;
check available capacity manually before proceeding.

To see the **To fix:** guidance in the Control Panel, unmount the backup
target (or use a path that is not a mountpoint), then choose **Back up now** on
Home and, separately, run an update from the Maintenance tab. Each stops with a
red result banner that gives the reason, then a **To fix:** line naming the mount
point (the update version also shows the one-off
`VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=true` command in monospace). Confirm
nothing was stopped or changed, that no prompt waited in the Details log, mount
the drive, and run the operation again to see it succeed. Setting an unsupported
value such as `BOAT_UNITS=imperial` in the settings file and starting an update
shows the same kind of line with the exact `sed` command.

If the backup media is intentionally unavailable in this disposable lab, the
one-shot exception is explicit and temporary:

Expand Down
Loading
Loading