Skip to content

Commit e4cd42d

Browse files
committed
feat: ui accept tgz / tar.gz
1 parent 15da8c6 commit e4cd42d

4 files changed

Lines changed: 41 additions & 22 deletions

File tree

ui/app.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,25 @@ def _shell(body: str) -> bytes:
127127
button.secondary {{ background: white; color: var(--ink); border-color: var(--line); }}
128128
.actions {{ display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }}
129129
.actions > * {{ width: auto; }}
130-
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
131-
th, td {{ border-bottom: 1px solid var(--line); padding: 9px; text-align: left; vertical-align: top; }}
132-
code {{ overflow-wrap: anywhere; }}
130+
.device-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }}
131+
.device-card {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px; min-width: 0; }}
132+
.device-card-head {{ display: flex; gap: 10px; align-items: center; justify-content: space-between; min-width: 0; }}
133+
.pick-row {{ display: flex; grid-template-columns: none; flex-direction: row; align-items: center; gap: 8px; min-width: 0; color: var(--ink); }}
134+
.pick-row input {{ width: auto; min-height: auto; }}
135+
.pick-row strong {{ overflow-wrap: anywhere; }}
136+
.device-meta {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 12px 0 0; }}
137+
.device-meta div {{ min-width: 0; }}
138+
.device-meta .wide {{ grid-column: 1 / -1; }}
139+
.device-meta dt {{ color: var(--muted); font-size: 12px; margin-bottom: 3px; }}
140+
.device-meta dd {{ margin: 0; min-width: 0; }}
141+
code {{ display: inline-block; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; }}
133142
.muted {{ color: var(--muted); }}
134143
.badge {{ border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; color: var(--ok); }}
135144
.notice {{ border-left: 4px solid var(--warn); padding: 10px 12px; background: #fff7ed; }}
136145
pre {{ max-height: 360px; overflow: auto; padding: 12px; background: #111827; color: #d1fae5; border-radius: 8px; }}
137146
iframe {{ width: 100%; min-height: 420px; border: 1px solid var(--line); border-radius: 8px; background: white; }}
138147
@media (max-width: 720px) {{
139-
table {{ display: block; overflow-x: auto; }}
148+
.device-meta {{ grid-template-columns: 1fr; }}
140149
.actions > * {{ width: 100%; }}
141150
}}
142151
</style>
@@ -197,7 +206,7 @@ def render_home(query: dict[str, list[str]], notice: str = "") -> bytes:
197206
<h2>Release Bundle</h2>
198207
<p class="muted">{html.escape(bundle_text)}</p>
199208
<form method="post" action="/import?workspace={html.escape(workspace_id)}" enctype="multipart/form-data">
200-
<label>Tarball<input type="file" name="tarball" accept=".tar,.tar.gz,.tgz"></label>
209+
<label>Tarball<input type="file" name="tarball" accept=".tar,.tar.gz,.tgz,application/gzip,application/x-tar"></label>
201210
<div class="actions"><button {"disabled" if not workspace else ""}>Import tarball</button></div>
202211
</form>
203212
</section>
@@ -233,10 +242,7 @@ def render_home(query: dict[str, list[str]], notice: str = "") -> bytes:
233242
<section>
234243
<h2>Generated Devices</h2>
235244
<form method="post" action="/flash?workspace={html.escape(workspace_id)}">
236-
<table>
237-
<thead><tr><th></th><th>Serial</th><th>VID / PID</th><th>Manual code</th><th>QR payload</th><th>Status</th></tr></thead>
238-
<tbody>{render_device_rows(devices, selected_serial)}</tbody>
239-
</table>
245+
<div class="device-grid">{render_device_rows(devices, selected_serial)}</div>
240246
<div class="grid">
241247
<label>Serial port<select name="port">{render_port_options(ports, selected_port)}</select></label>
242248
<label>Options<span><input type="checkbox" name="erase" value="1"> Erase first</span><span><input type="checkbox" name="monitor" value="1"> Monitor after flash</span></label>

ui/components/device_table.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@
77

88
def render_device_rows(devices: list[DeviceRow], selected_serial: str | None) -> str:
99
if not devices:
10-
return '<tr><td colspan="6" class="muted">No generated devices yet.</td></tr>'
10+
return '<p class="muted">No generated devices yet.</p>'
1111
rows = []
1212
for device in devices:
1313
checked = " checked" if device.serial_num == selected_serial else ""
1414
rows.append(
15-
"<tr>"
16-
f'<td><input type="radio" name="serial_num" value="{escape(device.serial_num)}"{checked}></td>'
17-
f"<td>{escape(device.serial_num)}</td>"
18-
f"<td>{escape(device.vendor_id)} / {escape(device.product_id)}</td>"
19-
f"<td><code>{escape(device.manualcode)}</code></td>"
20-
f"<td><code>{escape(device.qrcode)}</code></td>"
21-
f'<td><span class="badge">{escape(device.flash_status)}</span></td>'
22-
"</tr>"
15+
'<article class="device-card">'
16+
'<div class="device-card-head">'
17+
f'<label class="pick-row"><input type="radio" name="serial_num" value="{escape(device.serial_num)}"{checked}> '
18+
f'<strong>{escape(device.serial_num)}</strong></label>'
19+
f'<span class="badge">{escape(device.flash_status)}</span>'
20+
"</div>"
21+
'<dl class="device-meta">'
22+
f"<div><dt>VID / PID</dt><dd>{escape(device.vendor_id)} / {escape(device.product_id)}</dd></div>"
23+
f"<div><dt>Manual code</dt><dd><code>{escape(device.manualcode)}</code></dd></div>"
24+
f'<div class="wide"><dt>QR payload</dt><dd><code>{escape(device.qrcode)}</code></dd></div>'
25+
"</dl>"
26+
"</article>"
2327
)
2428
return "\n".join(rows)
25-

ui/services/release_bundle.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class ReleaseBundle:
1818
provisioning_supported: bool
1919

2020

21+
def is_supported_tarball(path: Path) -> bool:
22+
name = path.name.lower()
23+
return name.endswith((".tar", ".tar.gz", ".tgz"))
24+
25+
2126
def _sha256(path: Path) -> str:
2227
digest = hashlib.sha256()
2328
with path.open("rb") as file:
@@ -47,7 +52,7 @@ def _find_first(root: Path, filename: str) -> Path | None:
4752

4853
def import_release_tarball(workspace: Workspace, tarball_path: str | Path) -> ReleaseBundle:
4954
source = Path(tarball_path).expanduser().resolve()
50-
if source.suffix not in {".tar", ".gz", ".tgz"} and not source.name.endswith(".tar.gz"):
55+
if not is_supported_tarball(source):
5156
raise ValueError("Release bundle must be .tar, .tar.gz, or .tgz")
5257
if not source.is_file():
5358
raise FileNotFoundError(f"Release tarball not found: {source}")

ui/tests/test_release_bundle.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from unittest import mock
99

1010
from ui.services import workspace_store
11-
from ui.services.release_bundle import import_release_tarball
11+
from ui.services.release_bundle import import_release_tarball, is_supported_tarball
1212

1313

1414
def write_tar(path: Path, members: dict[str, bytes]) -> None:
@@ -20,6 +20,12 @@ def write_tar(path: Path, members: dict[str, bytes]) -> None:
2020

2121

2222
class ReleaseBundleTests(unittest.TestCase):
23+
def test_tarball_extension_filter_accepts_release_formats(self) -> None:
24+
self.assertTrue(is_supported_tarball(Path("release.tar")))
25+
self.assertTrue(is_supported_tarball(Path("release.tar.gz")))
26+
self.assertTrue(is_supported_tarball(Path("release.tgz")))
27+
self.assertFalse(is_supported_tarball(Path("release.gz")))
28+
2329
def test_rejects_path_traversal_member(self) -> None:
2430
with tempfile.TemporaryDirectory() as temp_dir:
2531
root = Path(temp_dir)
@@ -55,4 +61,3 @@ def test_detects_flasher_args_build_dir(self) -> None:
5561

5662
if __name__ == "__main__":
5763
unittest.main()
58-

0 commit comments

Comments
 (0)