From 27dcb7516bc322d73d761f6c25eba5fd81b137e2 Mon Sep 17 00:00:00 2001 From: edgarriba Date: Thu, 21 May 2026 08:21:32 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(spec):=20bubbaloop-dash=20Phase=201.1?= =?UTF-8?q?=20=E2=80=94=20upload=20pipeline=20+=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designs the user-facing video upload pipeline: chunked PUTs through the backend to filesystem storage, SQLite-backed progress tracking, integrity sweeper, and the Map tab containing the VideoUploader + VideoLibrary components. End state: drop iPhone clip → see it in the library; nothing decodes the video yet (Phase 1.2 lands the indexer). Scope of this phase (1.1): - REST endpoints: POST/PUT/DELETE /api/videos, /api/jobs, /api/blob - Storage Protocol + FilesystemStorage (S3-swappable later) - SQLite schema: videos + upload_progress + jobs tables with chunk bitmap - Background sweeper (asyncio task in lifespan) — marks abandoned, deletes orphans - Map tab in App.tsx, VideoUploader (single-file, in-session retry), VideoLibrary - 5-day phasing 1.1.a-1.1.h Out of scope: decode/embed/index (1.2), search UI (1.3), map canvas + SLAM (Spec 2), cross-session resume (deferred to 1.4 or never), multi-file batch (deferred to 1.4 or never), video probe/thumbnails (1.2). Key design simplification: no cross-session upload resume in v1. Interrupted upload = sweeper marks abandoned after 24h, user deletes + retries. Most real uploads finish in one session; the resume protocol's complexity isn't worth it for single-user self-hosted. Builds on Phase 1.0's spec Section 4 phasing table. Predecessor: v0.1.0-alpha shipped at kornia/bubbaloop-dash. Co-Authored-By: Claude Opus 4.7 --- ...6-05-21-bubbaloop-dash-phase-1.1-upload.md | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-bubbaloop-dash-phase-1.1-upload.md diff --git a/docs/superpowers/specs/2026-05-21-bubbaloop-dash-phase-1.1-upload.md b/docs/superpowers/specs/2026-05-21-bubbaloop-dash-phase-1.1-upload.md new file mode 100644 index 00000000..3b433fdb --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-bubbaloop-dash-phase-1.1-upload.md @@ -0,0 +1,630 @@ +# bubbaloop-dash Phase 1.1 — Upload + Library Design + +**Status:** Draft (brainstorm complete, awaiting user review) +**Date:** 2026-05-21 +**Owner:** Edgar Riba +**Builds on:** [Phase 1.0 design](2026-05-20-bubbaloop-dash-design.md) Section 4 (phasing table) + `2026-05-20-bubbaloop-dash-phase-1.0-skeleton.md` plan +**Predecessor:** Phase 1.0 (skeleton) — shipped at `kornia/bubbaloop-dash@v0.1.0-alpha` +**Successor:** Phase 1.2 (decode + index) + +--- + +## Executive summary + +Phase 1.1 makes the dashboard *useful for the first time*: users can upload mp4 clips from their browser into the bubbaloop-dash backend, see them appear in a library, and delete them. Nothing decodes the videos yet — they sit on disk waiting for Phase 1.2's indexer. The deliverable is end-to-end: drag a file onto the Map tab → see it appear in the sidebar library → server stores it durably → integrity sweeper protects against half-finished uploads. + +Phase 1.1 introduces the **Map tab** (placeholder canvas with the video library in the sidebar), the **filesystem-backed storage layer** (with an abstraction that lets Phase 2+ swap to S3 without API changes), and the **SQLite jobs/videos schema** that Phase 1.2's indexer will build on. + +**Out of scope (explicit):** no cross-session resume (an interrupted upload after a browser refresh = delete-and-retry, not auto-recover), no multi-file batching, no decode/embed/index, no search, no map canvas, no thumbnails. + +--- + +## Section 1 — Scope + phase boundary + +### Phase 1.1 owns + +| Subsystem | Concrete deliverable | +|---|---| +| **REST API** | `POST /api/videos`, `PUT /api/videos/{id}/chunks/{n}`, `POST /api/videos/{id}/complete`, `GET /api/videos`, `GET /api/videos/{id}`, `DELETE /api/videos/{id}`, `GET /api/jobs`, `GET /api/jobs/{id}`, `DELETE /api/jobs/{id}` | +| **Storage** | `Storage` Protocol in `dash.storage.base` + `FilesystemStorage` impl in `dash.storage.filesystem`. Pure file ops, no S3 yet. | +| **Persistence** | SQLite WAL at `${DATA_DIR}/state.sqlite`. Schema: `videos`, `upload_progress`, `jobs` tables. Connection opened in lifespan, closed on shutdown. | +| **Sweeper** | Background `asyncio` task, runs every `SWEEP_INTERVAL_S` (default 3600s). Marks `uploading` videos with no chunk in 24h as `abandoned`; deletes orphan source files. | +| **Map tab** | Add `"map"` to `AppView`, render `MapTab.tsx` (sidebar + canvas placeholder). `MapCanvas.tsx` is a "SLAM coming soon" div. | +| **Upload UI** | `VideoUploader.tsx` (drag-or-pick, single file at a time, in-session retry, cancel). | +| **Library UI** | `VideoLibrary.tsx` (list of videos with status badges, progress bar for in-progress, delete confirm). | + +### Phase 1.1 does NOT own (deferred to 1.2+) + +| Out-of-scope item | Phase that owns it | +|---|---| +| PyAV decoder | Phase 1.2 | +| open_clip embedder | Phase 1.2 | +| Background job runner (picks up `ready` videos, decodes, embeds, writes LanceDB) | Phase 1.2 | +| Job states beyond `uploading / ready / error / abandoned` (`queued`, `indexing`, `done`, `interrupted`) | Phase 1.2 | +| LanceDB `videos` + `frames` writes (duration, codec, fps, embeddings) | Phase 1.2 | +| Video probe (ffprobe / PyAV.open) for duration/codec metadata in library | Phase 1.2 | +| Frame thumbnails in the library | Phase 1.2 | +| Search UI (FrameSearch, FrameGrid) | Phase 1.3 | +| Map canvas (kornia-slam, sensor placement) | Spec 2 | +| Cross-session upload resume (browser refresh recovery) | Phase 1.4 polish, possibly never (delete-and-retry is fine) | +| Multi-file batch upload | Phase 1.4 polish, possibly never | +| Image-similarity search input (jpg_b64 / video_id+frame_idx) | Phase 1.5 | + +### Job state machine (Phase 1.1 only) + +``` + ┌───────────────┐ + POST │ │ POST /complete OK + /videos ──▶ uploading │─────────────────────▶ ready + │ │ + └─┬───────────┬─┘ + │ │ POST /complete fails (size mismatch) + │ │ OR PUT chunk fails (disk full) + │ └────────────────────────▶ error + │ + │ sweeper: last_chunk_at > 24h ago + └────────────────────────────────────▶ abandoned + + DELETE /api/videos/{id} from any state → row + files removed +``` + +Phase 1.2 will add: `ready → queued → indexing → done | error | interrupted`. + +### Map tab placement + +- `AppView` becomes: `"dashboard" | "loop" | "map" | "chat"` (insert between Loop and Chat). +- Tab icon: map-pin SVG (stroke-only, matches existing tab style). +- Layout: 360px sidebar (Library + Uploader) + flex-1 canvas (placeholder). + +--- + +## Section 2 — Storage layer + SQLite schema + +### Storage abstraction + +```python +# backend/src/dash/storage/base.py +from typing import Protocol + +class Storage(Protocol): + def write_chunk(self, path: str, offset: int, data: bytes) -> int: + """Atomically write `data` at byte `offset` of `path`. Returns bytes written. + Creates the file (and parent dirs) if missing. Safe for concurrent writes to + non-overlapping offsets within the same file.""" + def read(self, path: str) -> bytes: ... + def url_for(self, path: str) -> str: + """Returns a URL the browser can fetch the file from. For FilesystemStorage, + an /api/blob/ route; for future S3Storage, a presigned GET URL.""" + def delete(self, path: str) -> None: + """Idempotent — deleting a missing path is not an error.""" + def size(self, path: str) -> int: + """Returns the current size in bytes of `path`. Raises FileNotFoundError if missing.""" + def exists(self, path: str) -> bool: ... +``` + +### FilesystemStorage + +```python +# backend/src/dash/storage/filesystem.py +import os +from pathlib import Path + +class FilesystemStorage: + def __init__(self, root: Path) -> None: + self._root = root.resolve() + + def _abs(self, path: str) -> Path: + # Path normalization + traversal protection. + candidate = (self._root / path).resolve() + if not candidate.is_relative_to(self._root): + raise ValueError(f"path escapes storage root: {path}") + return candidate + + def write_chunk(self, path: str, offset: int, data: bytes) -> int: + abs_path = self._abs(path) + abs_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(abs_path, os.O_WRONLY | os.O_CREAT, 0o644) + try: + return os.pwrite(fd, data, offset) + finally: + os.close(fd) + + # read / url_for / delete / size / exists trivial. +``` + +`os.pwrite` is atomic per-call within the kernel for non-overlapping byte ranges — two concurrent `write_chunk` calls to disjoint offsets of the same file are safe without explicit locking. + +`url_for` returns `/api/blob/`. A new tiny route serves blobs from the filesystem (auth-protected). For Phase 1.1 this is mainly used for the future `` thumbnail tag in 1.2+; in 1.1 the route exists but no UI consumes it. + +### Storage layout + +``` +${DATA_DIR}/ +├── state.sqlite # SQLite WAL +├── state.sqlite-wal +├── state.sqlite-shm +├── .token # Phase 1.0 +└── videos/ + └── / + └── source. # original bytes; ext sanitized from upload filename +``` + +**Extension sanitization:** uppercase the filename, take substring after the last `.`, lowercase, allow only `[a-z0-9]{1,8}`. Default to `bin` if invalid. We don't care about real format validation — PyAV in Phase 1.2 reads the container from content, not extension. + +### SQLite schema + +```sql +-- backend/src/dash/db/schema.sql + +CREATE TABLE IF NOT EXISTS videos ( + id TEXT PRIMARY KEY, -- uuid4 hex + filename TEXT NOT NULL, -- user-displayable original + content_type TEXT NOT NULL, -- 'video/mp4', etc. + ext TEXT NOT NULL, -- sanitized extension used on disk + size_bytes INTEGER NOT NULL, -- total size declared at POST /api/videos + chunk_size INTEGER NOT NULL, -- server-decided, e.g., 8 MB + total_chunks INTEGER NOT NULL, -- ceil(size_bytes / chunk_size) + status TEXT NOT NULL, -- 'uploading' | 'ready' | 'error' | 'abandoned' + error TEXT, -- nullable; reason if status='error' + created_at INTEGER NOT NULL, -- epoch ms + updated_at INTEGER NOT NULL, -- epoch ms (last status transition or chunk) + completed_at INTEGER -- epoch ms; set when status → 'ready' +); + +CREATE TABLE IF NOT EXISTS upload_progress ( + video_id TEXT PRIMARY KEY REFERENCES videos(id) ON DELETE CASCADE, + received_chunks BLOB NOT NULL, -- packed bitmap, ceil(total_chunks/8) bytes + received_bytes INTEGER NOT NULL DEFAULT 0, -- sum of declared chunk sizes received + last_chunk_at INTEGER NOT NULL -- epoch ms; sweeper uses this for 'abandoned' +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, -- uuid4 hex + video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'upload' (1.1); 'indexing' (1.2) + status TEXT NOT NULL, -- mirrors videos.status for kind='upload' + progress_pct INTEGER NOT NULL DEFAULT 0, + error TEXT, + started_at INTEGER NOT NULL, + finished_at INTEGER -- nullable until terminal state +); + +CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status); +CREATE INDEX IF NOT EXISTS idx_videos_updated ON videos(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_jobs_video ON jobs(video_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +``` + +### Chunk bitmap encoding + +- **On disk** (BLOB column): packed bits, `ceil(total_chunks / 8)` bytes. Bit `i` set = chunk `i` received. For a 3 GB / 8 MB-chunk video → 375 chunks → 47 bytes. +- **NOT exposed in API** (Phase 1.1): client doesn't get the bitmap because we're not supporting cross-session resume. +- **Used server-side** to validate completeness at `POST /complete` — all bits must be set. Catches "client claimed done but missed a middle chunk" bugs. + +### Cross-session resume — explicit non-goal + +The browser's `File` object does not survive page reload. To "resume" after reload we'd need the user to re-pick the same file, and the server to validate it's the same file (size + content hash prefix). For Phase 1.1 we don't do this — an interrupted upload becomes an `abandoned` video after 24h; user deletes and re-uploads. **This may never become a requirement.** Most real-world uploads finish in one session; the sweeper handles the cleanup. + +### Database connection + +```python +# backend/src/dash/db/sqlite.py +class Db: + def __init__(self, conn: sqlite3.Connection): self._conn = conn + + @classmethod + def open(cls, path: Path) -> "Db": + conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None) + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 5000") + conn.executescript(SCHEMA_SQL) + return cls(conn) + + def create_video(self, ...) -> str: ... + def record_chunk(self, video_id: str, chunk_idx: int, bytes_written: int) -> None: ... + def mark_ready(self, video_id: str) -> None: ... + def mark_error(self, video_id: str, reason: str) -> None: ... + def mark_abandoned(self, video_id: str) -> None: ... + def list_videos(self, status_filter: str | None) -> list[VideoSummary]: ... + def get_video(self, video_id: str) -> VideoDetail | None: ... + def get_upload_progress(self, video_id: str) -> UploadProgress | None: ... + def delete_video(self, video_id: str) -> None: ... + def list_jobs(self, status_filter: str | None) -> list[JobSummary]: ... + def get_job(self, job_id: str) -> JobDetail | None: ... +``` + +Connection opened in `factory.py` lifespan, stamped on `app.state.db`. Single connection per app process; SQLite serializes writes via WAL anyway, and we don't have enough write traffic to warrant a pool. + +`check_same_thread=False` because FastAPI handlers run on the same event loop but may dispatch DB operations from threadpool executors. `isolation_level=None` (autocommit); explicit transactions in `Db` methods that need atomicity. + +--- + +## Section 3 — REST handlers + sweeper + +### Endpoint contracts + +#### `POST /api/videos` +**Body:** +```json +{ + "filename": "entrance_2026-05-21.mp4", + "size_bytes": 3221225472, + "content_type": "video/mp4" +} +``` +**Response 200:** +```json +{ + "video_id": "1a2b3c4d...", + "chunk_size": 8388608, + "total_chunks": 384 +} +``` +**Behavior:** validate `size_bytes` ≤ `MAX_UPLOAD_BYTES` (default 16 GB, env override). Validate `content_type` starts with `video/`. Generate uuid. Sanitize extension from filename. Insert `videos` row with `status='uploading'`. Insert `upload_progress` row with empty bitmap. Insert `jobs` row with `kind='upload'`, `status='uploading'`. Allocate `${DATA_DIR}/videos//` directory. Returns server-decided `chunk_size` (8 MB). + +#### `PUT /api/videos/{id}/chunks/{n}` +**Headers:** `Content-Length: ` +**Body:** raw binary chunk bytes +**Response 204:** no body +**Behavior:** lookup video; if status != `uploading`, return 409. Validate `n` < `total_chunks`. Validate chunk size: +- if `n < total_chunks - 1`: must equal `chunk_size` +- if `n == total_chunks - 1`: must equal `size_bytes - n*chunk_size` (the remainder) + +Write to `storage.write_chunk(f"videos/{id}/source.{ext}", offset=n*chunk_size, data=body)`. Update `upload_progress`: set bit `n` in bitmap, increment `received_bytes` (only if bit was previously unset — idempotent re-uploads of same chunk don't double-count). Update `last_chunk_at`. Update `jobs.progress_pct = floor(100 * popcount(bitmap) / total_chunks)`. + +#### `POST /api/videos/{id}/complete` +**Body:** `{}` (reserved for future `sha256` integrity field) +**Response 200:** +```json +{ + "video_id": "1a2b3c4d...", + "status": "ready" +} +``` +**Behavior:** lookup video. Read `upload_progress`. If any bit unset → return 422 with detail `"missing_chunks: [3, 17, 22]"` (first 10). If `storage.size(path) != size_bytes` → return 422 `"size_mismatch"`, mark video `error`, delete partial file. Otherwise: set `videos.status='ready'`, `completed_at=now`, `jobs.status='ready'`, `jobs.finished_at=now`, `jobs.progress_pct=100`. + +#### `GET /api/videos` +**Query:** `?status=ready,uploading&limit=100&cursor=` +**Response 200:** +```json +{ + "items": [ + { + "id": "1a2b...", + "filename": "entrance.mp4", + "size_bytes": 3221225472, + "status": "ready", + "created_at": 1747800000000, + "updated_at": 1747800120000, + "completed_at": 1747800120000, + "progress_pct": 100 + } + ], + "next_cursor": null +} +``` +**Behavior:** ORDER BY `updated_at DESC`, status-filter optional, default limit 100, cursor-based pagination (cursor = base64-encoded `updated_at`+`id` tuple). Phase 1.1 has no real pagination need (assumed dozens of videos max) but the shape is stable. + +#### `GET /api/videos/{id}` +**Response 200:** as above row, plus: +```json +{ + "error": null, + "_links": { + "blob": "/api/blob/videos/1a2b.../source.mp4", + "delete": "/api/videos/1a2b..." + } +} +``` +**404** if missing. + +#### `DELETE /api/videos/{id}` +**Response 204** on success. +**Behavior:** cascade. Delete `videos` row (FK CASCADE handles `upload_progress` and `jobs`). Delete `${DATA_DIR}/videos//` directory recursively. Idempotent (404 if already gone is fine, but we return 204 either way). + +#### `GET /api/jobs` +**Query:** `?status=&kind=&limit=100` +**Response 200:** +```json +{ + "items": [ + { + "id": "j1a2b...", + "video_id": "1a2b...", + "kind": "upload", + "status": "ready", + "progress_pct": 100, + "started_at": 1747800000000, + "finished_at": 1747800120000 + } + ] +} +``` + +#### `GET /api/jobs/{id}` +Single job detail; same shape as a job item. + +#### `DELETE /api/jobs/{id}` +**Phase 1.1 behavior:** for `kind='upload'`, equivalent to `DELETE /api/videos/{job.video_id}`. The upload IS the work. Phase 1.2 makes this more meaningful (cancel a running indexer). + +#### `GET /api/blob/{path...}` +**Response:** `image/jpeg` or `video/mp4` bytes, streamed via `FileResponse`. Path traversal protected (uses `Storage.url_for` mapping). Phase 1.1 has no real consumer; Phase 1.2 uses for thumbnails. + +### Auth + +All endpoints `Depends(require_bearer_token)`. The blob route serves bytes; do not skip auth here. + +### Sweeper + +```python +# backend/src/dash/jobs/sweeper.py +async def sweeper_loop(db: Db, storage: Storage, interval_s: int, abandoned_after_s: int = 86400) -> None: + while True: + await asyncio.sleep(interval_s) + try: + run_sweep(db, storage, abandoned_after_s=abandoned_after_s) + except Exception: + _log.exception("sweeper.failed") + +def run_sweep(db: Db, storage: Storage, abandoned_after_s: int) -> SweepReport: + now = epoch_ms() + cutoff = now - abandoned_after_s * 1000 + # 1. Mark stale uploads abandoned. + stale = db.find_stale_uploads(cutoff) # status='uploading' AND last_chunk_at < cutoff + for v in stale: + db.mark_abandoned(v.id) + _log.warning("sweep.abandoned", video_id=v.id, last_chunk_at=v.last_chunk_at) + + # 2. Delete orphan source files (no matching videos row). + orphans = list_orphan_files(db, storage) + for path in orphans: + storage.delete(path) + _log.info("sweep.orphan_deleted", path=path) + + return SweepReport(abandoned=len(stale), orphans_deleted=len(orphans)) +``` + +Launched from `factory.py` lifespan as a tokio-style background task (`asyncio.create_task`) alongside the Zenoh session. Cancelled on shutdown. `run_sweep` is its own function so we can unit-test it without the loop. + +--- + +## Section 4 — Frontend (Map tab, VideoUploader, VideoLibrary) + +### App.tsx tab addition + +```diff +- type AppView = "dashboard" | "loop" | "chat"; ++ type AppView = "dashboard" | "loop" | "map" | "chat"; +``` + +Three lines: add the map-pin SVG button between Loop and Chat; route `currentView === "map"` to ``; pass through `availableTopics` / `session` for future use (Spec 2's sensor placement). + +### MapTab.tsx + +```tsx +export function MapTab() { + const [selectedVideoId, setSelectedVideoId] = useState(null); + return ( +
+ +
+ {/* placeholder for Spec 2 */} +
+
+ ); +} +``` + +Sidebar fixed-width 360px on desktop, full-width drawer on mobile (< 768px). `MapCanvas` is a 40-line placeholder with "SLAM coming soon" + an empty `` element Spec 2 hooks into. + +### VideoUploader.tsx + +``` ++-- VideoUploader ----------------------------------+ +| | +| ┌─────────────────────────────────────────────┐ | +| │ Drop a video here or │ | +| │ [ Browse... ] │ | +| └─────────────────────────────────────────────┘ | +| | +| -- when uploading: -- | +| ┌─────────────────────────────────────────────┐ | +| │ entrance.mp4 (3.2 GB) │ | +| │ ██████████░░░░░░░░░░ 47% 12 MB/s │ | +| │ [ Cancel ] │ | +| └─────────────────────────────────────────────┘ | ++---------------------------------------------------+ +``` + +**Behavior (single-file, in-session retry only):** + +1. User drops or picks a file. ``. +2. POST `/api/videos { filename, size_bytes, content_type }` → `{ video_id, chunk_size, total_chunks }`. +3. Loop: read chunks via `file.slice(offset, offset + chunk_size).arrayBuffer()`; PUT to `/api/videos//chunks/`. Maintain a `chunkConcurrency = 3` semaphore (e.g., simple counter + queue). +4. Per-chunk: exponential backoff retry on network failure (250, 500, 1000, 2000, 4000 ms); max 5 attempts. On final failure, abort the whole upload (DELETE /api/videos/) and surface error. +5. On user `[Cancel]`: AbortController on in-flight fetches; DELETE the video; reset UI. +6. After last chunk: POST `/api/videos//complete`. On 2xx, call `onUploaded(video_id)`; reset UI to drop-zone. +7. While uploading, the drop-zone is disabled (can't start a second concurrent upload). + +**No cross-session recovery.** If the user closes the tab mid-upload, the partial mp4 sits server-side until the sweeper marks it `abandoned`. Library will show it as "uploading" with stale `last_chunk_at`; user deletes and re-uploads from a fresh session. + +**Speed indicator:** rolling 5-second window average of `received_bytes / elapsed_ms`. Display as `MB/s`. Useful UX, no protocol cost. + +**State (single component):** +```typescript +type UploaderState = + | { kind: "idle" } + | { kind: "starting"; file: File } + | { kind: "uploading"; file: File; videoId: string; receivedBytes: number; totalBytes: number; speedMBps: number; abortController: AbortController } + | { kind: "completing"; file: File; videoId: string } + | { kind: "error"; reason: string }; +``` + +### VideoLibrary.tsx + +``` ++-- VideoLibrary -----------------------------------+ +| Library [⟳] | +| | +| ┌─────────────────────────────────────────────┐ | +| │ ▸ entrance_2026-05-21.mp4 3.2 GB │ | +| │ ✓ ready · just now │ | +| │ […] │ | +| └─────────────────────────────────────────────┘ | +| ┌─────────────────────────────────────────────┐ | +| │ ▸ patio.mp4 142 MB │ | +| │ ██████████░░░░ uploading 47% · 8 MB/s │ | +| │ [×] │ | +| └─────────────────────────────────────────────┘ | +| ┌─────────────────────────────────────────────┐ | +| │ ▸ kitchen.mp4 1.8 GB │ | +| │ ⚠ interrupted · last chunk 18m ago │ | +| │ [Delete] │ | +| └─────────────────────────────────────────────┘ | +| ┌─────────────────────────────────────────────┐ | +| │ ▸ broken.mp4 220 MB │ | +| │ ✗ error: size mismatch [Delete] │ | +| └─────────────────────────────────────────────┘ | ++---------------------------------------------------+ +``` + +**Per-row visual logic:** + +| `status` | Badge | Right-side action | +|---|---|---| +| `ready` | ✓ ready · ** | `[…]` menu → Delete | +| `uploading` (currently active in this tab) | progress bar + speed | `[×]` Cancel | +| `uploading` (no chunk in >10min, NOT currently this tab) | ⚠ interrupted · last chunk *