feat: add descriptive model naming and inline rename

- Auto-generate model names: {arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}
- Add PATCH /api/models/{id}/rename endpoint
- Inline rename UI on Models & Training page
- Download filename uses model name instead of v{N}
- DB migration: add name column to model_versions
- Update all docs to reflect new naming convention
This commit is contained in:
Andrew-AAAA committed 2026-09-10 09:14:55 +07:00
1 parent 51e74a253e
commit d170cff0e4
14 files changed
+166 -32

No files matched your search

+1
View File
@@ -151,6 +151,7 @@ erDiagram
integer id PK "AUTOINCREMENT"
integer project_id FK "References projects(id)"
integer version "Incrementing version integer"
text name "Descriptive model name (arch-labelType-epochs-classes-date)"
text weights_path "Path to trained best.pt weights"
text parent_model_path "Path to base model used as starting point"
text metrics "Trained model evaluation metrics JSON"
+1
View File
@@ -562,6 +562,7 @@ data/
│ ├── images/{train,val}/ # Immutable frame images
│ └── labels/{train,val}/ # YOLO format bounding box annotations (.txt)
└── models/<n>/ # Training runs (weights/best.pt, metrics.json, args.yaml)
# Named: {arch}-{labelType}-{epochs}ep-{classNames}-{date}
```
---
+1 -1
View File
@@ -46,7 +46,7 @@ def available_models(project_id: int) -> dict:
for version in training.listing(project_id):
if version.get("weights_path") and os.path.isfile(version["weights_path"]):
out.append({
"label": f"v{version['version']}",
"label": version.get("name") or f"v{version['version']}",
"path": version["weights_path"],
"version_id": version["id"],
})
+17 -1
View File
@@ -57,8 +57,9 @@ def download_weights(model_id: int):
version = training.get_version(model_id)
if version is None or not os.path.isfile(version["weights_path"]):
raise HTTPException(404, "No weights for that version")
name = version.get('name') or f"v{version['version']}"
return FileResponse(version["weights_path"], media_type="application/octet-stream",
filename=f"v{version['version']}-best.pt")
filename=f"{name}-best.pt")
@router.post("/api/models/{model_id}/promote")
@@ -67,3 +68,18 @@ def promote_model(model_id: int) -> dict:
return training.promote(model_id)
except training.TrainingError as exc:
raise HTTPException(400, str(exc))
class RenameRequest(BaseModel):
name: str
@router.patch("/api/models/{model_id}/rename")
def rename_model(model_id: int, request: RenameRequest) -> dict:
try:
version = training.rename(model_id, request.name)
if version is None:
raise HTTPException(404, "No such model version")
return version
except training.TrainingError as exc:
raise HTTPException(400, str(exc))
+46
View File
@@ -270,6 +270,8 @@ def migrate() -> None:
# REQ-113: and what augmentation it trained under.
if "augment" not in version_cols:
cur.execute("ALTER TABLE model_versions ADD COLUMN augment TEXT")
if "name" not in version_cols:
cur.execute("ALTER TABLE model_versions ADD COLUMN name TEXT")
# REQ-132: the triage rules this dataset was actually cut under, frozen
# at merge time. Editing project rules afterwards must not rewrite what
@@ -284,6 +286,7 @@ def migrate() -> None:
_migrate_job_types(cur)
_migrate_clock_column(cur)
_migrate_truck_columns(cur)
_backfill_model_names(cur)
def _migrate_dataset_items(cur) -> None:
@@ -391,6 +394,49 @@ def _migrate_job_types(cur) -> None:
cur.execute("DROP TABLE jobs_old")
def _backfill_model_names(cur) -> None:
"""Give existing model versions a human-readable name based on training params."""
import time as _time
PRETRAINED = {"bbox": "yolo11n.pt", "polygon": "yolo11n-seg.pt"}
cur.execute("SELECT id, project_id, version, parent_model_path, created_at, augment "
"FROM model_versions WHERE name IS NULL")
rows = cur.fetchall()
if not rows:
return
for model_id, project_id, version, parent_path, created_at, augment_json in rows:
# Resolve architecture name from parent model path or project fallback.
cur.execute("SELECT label_type FROM projects WHERE id = ?", (project_id,))
proj_row = cur.fetchone()
if proj_row is None:
continue
label_type = proj_row[0]
fallback = PRETRAINED.get(label_type, "yolo11n.pt")
if parent_path:
arch = os.path.splitext(os.path.basename(parent_path))[0]
else:
arch = os.path.splitext(os.path.basename(fallback))[0]
# Epochs: default 50 (augment JSON doesn't store epochs).
epochs = 50
# Class names.
cur.execute(
"SELECT name FROM project_classes WHERE project_id = ? ORDER BY class_id",
(project_id,),
)
class_names = "+".join(r[0] for r in cur.fetchall()) or "unknown"
# Date.
date_str = _time.strftime("%Y%m%d", _time.localtime(created_at))
name = f"{arch}-{label_type}-{epochs}ep-{class_names}-{date_str}"
cur.execute("UPDATE model_versions SET name = ? WHERE id = ?", (name, model_id))
def _backfill_dataset_rules(cur) -> None:
"""Datasets merged before REQ-132 have no snapshot. Give them the project's
current rules — that is what they were cut under, unless the rules changed
+29 -3
View File
@@ -12,6 +12,21 @@ import shutil
import time
from typing import Optional
def _generate_model_name(project: dict, start_point: str, epochs: int,
class_ids: Optional[list] = None) -> str:
"""Build a descriptive model name: arch-labelType-epochs-classNames-YYYYMMDD."""
arch = os.path.splitext(os.path.basename(start_point))[0]
# Filter classes if specific IDs were selected.
classes = project["classes"]
if class_ids:
classes = [c for c in classes if c["class_id"] in class_ids]
class_tag = "+".join(c["name"] for c in classes) or "unknown"
date_str = time.strftime("%Y%m%d", time.localtime())
return f"{arch}-{project['label_type']}-{epochs}ep-{class_tag}-{date_str}"
from backend import (augment, base_dataset, config, dataset, datasets, db, evaluate,
hardware, jobs, projects)
@@ -149,6 +164,16 @@ def promote(model_id: int) -> dict:
return projects.get(project["id"])
def rename(model_id: int, name: str) -> dict:
"""Update a model version's human-readable name."""
version = get_version(model_id)
if version is None:
raise TrainingError("No such model version")
with db.cursor() as cur:
cur.execute("UPDATE model_versions SET name = ? WHERE id = ?", (name.strip(), model_id))
return get_version(model_id)
def _next_version(cur, project_id: int) -> int:
cur.execute(
"SELECT COALESCE(MAX(version), 0) + 1 FROM model_versions WHERE project_id = ?",
@@ -262,11 +287,12 @@ def _run_train(job) -> None:
cur.execute(
"""INSERT INTO model_versions (project_id, version, weights_path,
parent_model_path, metrics, base_metrics,
created_at, augment)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
created_at, augment, name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(project["id"], version, weights, project["base_model_path"],
json.dumps(comparison["new"]), json.dumps(comparison["base"]), time.time(),
json.dumps(augmentation["settings"])),
json.dumps(augmentation["settings"]),
_generate_model_name(project, start_point, settings["epochs"], class_ids)),
)
new = comparison["new"]
+1
View File
@@ -151,6 +151,7 @@ erDiagram
integer id PK "AUTOINCREMENT"
integer project_id FK "References projects(id)"
integer version "Incrementing version integer"
text name "Descriptive model name (arch-labelType-epochs-classes-date)"
text weights_path "Path to trained best.pt weights"
text parent_model_path "Path to base model used as starting point"
text metrics "Trained model evaluation metrics JSON"
+16 -15
View File
@@ -229,7 +229,7 @@ Parameter pada form pembuatan proyek:
*Perhatian: Jenis geometri terkunci permanen setelah batch pertama digabungkan ke dataset.*
3. **Val Split (Langkah Validasi)**: Nilai integer $N$ (default $5$). Menentukan bahwa setiap citra ke-$N$ secara konsisten dialokasikan sebagai data validasi (rasio $1/N = 20\%$).
4. **Video Archive Root**: Jalur direktori arsip video CCTV (default `/videos`).
5. **Model Checkpoint**: Opsi untuk mengunggah bobot awal `.pt` (misalnya `v4-best.pt`). Jika disediakan, sistem secara otomatis mengekstrak nama kelas dari metadata bobot (`model.names`).
5. **Model Checkpoint**: Opsi untuk mengunggah bobot awal `.pt` (misalnya `yolo11n-bbox-100ep-sack+box-20260909-best.pt`). Jika disediakan, sistem secara otomatis mengekstrak nama kelas dari metadata bobot (`model.names`).
## 3.3 Taksonomi Kelas & Pemetaan Prompt Teks
Tabel definisi kelas dan pemetaan prompt pada proyek deteksi karung pakan:
@@ -271,12 +271,12 @@ Prosedur penanganan stempel waktu:
3. Apabila skor kecocokan glif OCR berada di bawah ambang batas ($<0.85$), baris siklus ditandai dengan ikon lingkaran amber (perlu verifikasi).
4. Operator dapat mengklik teks waktu pada antarmuka dan mengetikkan koreksi jam secara manual.
## 4.3 Pemindaian Truk Massal Terotomatisasi (Truck Scan v4)
Tidak semua video arsip memuat aktivitas bongkar muat karung. Untuk menghemat waktu anotasi, sistem menyediakan fitur pemindaian truk menggunakan model `v4-best.pt`.
## 4.3 Pemindaian Truk Massal Terotomatisasi (Truck Scan)
Tidak semua video arsip memuat aktivitas bongkar muat karung. Untuk menghemat waktu anotasi, sistem menyediakan fitur pemindaian truk menggunakan model terlatih terbaru.
Langkah operasional:
1. Buka halaman Library proyek (`/projects/<id>`).
2. Klik tombol `Cek truk (v4)` pada header tabel arsip.
2. Klik tombol `Cek truk` pada header tabel arsip.
3. Server mengeksekusi inferensi berkecepatan tinggi pada sampel frame video terpilih (1 frame per 10 detik).
4. Kolom `Truk` pada tabel akan menampilkan rasio keberadaan truk:
- **Badge Hijau (contoh `12/12`)**: Truk terdeteksi konsisten, video siap dipotong dan dianotasi.
@@ -362,12 +362,12 @@ Apabila operator memiliki puluhan batch rekaman yang baru diekstraksi, gunakan f
![Modal Auto-Anotasi Massal](screenshots/07_batches_mass_auto_annotate_modal.png)
*Gambar 7: Modal Auto-Anotasi Massal (Mass Auto-Annotate Modal).* Pilihan engine pelabelan (SAM3 Zero-Shot, Base Model v4, atau Model Kustom), daftar centang batch target, dan tombol eksekusi antrean sekuensial.
*Gambar 7: Modal Auto-Anotasi Massal (Mass Auto-Annotate Modal).* Pilihan engine pelabelan (SAM3 Zero-Shot, Base Model terlatih, atau Model Kustom), daftar centang batch target, dan tombol eksekusi antrean sekuensial.
*(English label: Mass Auto-Annotation Modal)*
Opsi engine pelabelan:
- **SAM3 Zero-Shot**: Menggunakan Meta SAM3 dengan prompt teks proyek. Sangat fleksibel untuk objek baru.
- **Project Base Model**: Menggunakan model YOLO proyek yang sedang aktif (misalnya `v4-best.pt`). Kecepatan inferensi jauh lebih tinggi dibandingkan SAM3.
- **Project Base Model**: Menggunakan model YOLO proyek yang sedang aktif. Kecepatan inferensi jauh lebih tinggi dibandingkan SAM3.
- **Custom YOLO Model**: Menggunakan file checkpoint `.pt` khusus yang diunggah operator.
Seluruh proses massal dieksekusi secara sekuensial oleh worker backend di bawah proteksi `jobs.gpu_lock` untuk mencegah benturan VRAM.
@@ -607,7 +607,7 @@ Pelatihan model dilakukan melalui antarmuka Models (`/projects/{id}/models`).
*(English label: YOLO Model Training & Metrics)*
Langkah konfigurasi pelatihan:
1. **Pilih Base Model Weights**: Pilih checkpoint dasar (misalnya `v4-best.pt` atau bobot pretrained Ultralytics `yolo11n.pt` / `yolo11n-seg.pt`).
1. **Pilih Base Model Weights**: Pilih checkpoint dasar (misalnya model terlatih sebelumnya atau bobot pretrained Ultralytics `yolo11n.pt` / `yolo11n-seg.pt`).
2. **Target Classes**: Pilih kelas deteksi yang akan dilatih.
3. **Epochs**: Masukkan jumlah siklus pelatihan (default 50 epoch, rekomendasi 30 sampai 100 epoch untuk fine-tuning).
4. **Hardware Auto-Probe**: Sistem memeriksa kapasitas VRAM GPU host secara otomatis:
@@ -647,20 +647,20 @@ Tabel metrik evaluasi model:
| Versi Model | Status | mAP50 | mAP50-95 | Precision | Recall | Signed Delta $\Delta$ mAP50 | Status Keputusan |
|---|---|---|---|---|---|---|---|
| `v1` | Arsip | 0.884 | 0.612 | 0.891 | 0.875 | Basis Awal | Model Awal |
| `v2` | Arsip | 0.912 | 0.654 | 0.920 | 0.898 | `+0.028` (Hijau) | Ditingkatkan |
| `v3` | Arsip | 0.938 | 0.701 | 0.942 | 0.925 | `+0.026` (Hijau) | Ditingkatkan |
| `v4` | **Base Aktif** | 0.965 | 0.748 | 0.968 | 0.952 | `+0.027` (Hijau) | **Standar Produksi** |
| `v5` | Kandidat | 0.978 | 0.772 | 0.981 | 0.969 | `+0.013` (Hijau) | Siap Dipromosikan |
| `yolo11n-bbox-50ep-sack+box-20260909` | Arsip | 0.884 | 0.612 | 0.891 | 0.875 | Basis Awal | Model Awal |
| `yolo11n-bbox-100ep-sack+box-20260910` | Arsip | 0.912 | 0.654 | 0.920 | 0.898 | `+0.028` (Hijau) | Ditingkatkan |
| `yolo11n-bbox-100ep-sack+box-20260911` | Arsip | 0.938 | 0.701 | 0.942 | 0.925 | `+0.026` (Hijau) | Ditingkatkan |
| `yolo11n-bbox-200ep-sack+box-20260912` | **Base Aktif** | 0.965 | 0.748 | 0.968 | 0.952 | `+0.027` (Hijau) | **Standar Produksi** |
| `yolo11n-bbox-200ep-sack+box-20260913` | Kandidat | 0.978 | 0.772 | 0.981 | 0.969 | `+0.013` (Hijau) | Siap Dipromosikan |
Penjelasan nilai Delta $\Delta$:
- **Nilai Positif Hijau (`+0.013`)**: Menandakan model baru memiliki akurasi deteksi lebih unggul pada data validasi.
- **Nilai Negatif Merah (`-0.015`)**: Menandakan terjadi penurunan performa (*model regression*); model baru sebaiknya tidak dipromosikan.
## 10.5 Promosi Model Baru (Model Promotion)
Jika model kandidat (misalnya `v5`) terbukti menghasilkan delta mAP positif dan lolos pengujian:
Jika model kandidat terbukti menghasilkan delta mAP positif dan lolos pengujian:
1. Klik tombol `Use as base model` pada baris model tersebut.
2. Sistem secara atomik menyalin file bobot `data/projects/<slug>/models/5/best.pt` ke jalur model dasar proyek `data/projects/<slug>/base/model.pt`.
2. Sistem secara atomik menyalin file bobot ke jalur model dasar proyek `data/projects/<slug>/base/model.pt`.
3. Model baru langsung aktif sebagai rujukan utama untuk modul pemindaian truk, auto-labeling, dan mesin live counting.
# Bab 11: Sistem Live Counting & Integrasi Kamera CCTV
@@ -831,7 +831,7 @@ Tabel inventaris skema database:
| `base_datasets` | 6 | `id` | Pendaftaran dataset eksternal (kontributor data latih khusus). |
| `video_clock` | 6 | `id` | Hasil pembacaan jam OCR CCTV, tanggal siklus 06:00, dan status verifikasi. |
| `count_runs` | 12 | `id` | Hasil kalkulasi counting AI, nilai Ground Truth manual, dan signed delta. |
| `model_versions` | 10 | `id` | Versi model hasil pelatihan, path file `best.pt`, dan rekam `metrics.json`. |
| `model_versions` | 11 | `id` | Versi model hasil pelatihan, nama deskriptif, path file `best.pt`, dan rekam `metrics.json`. |
| `jobs` | 9 | `id` | Antrean tugas latar belakang server (ekstraksi, auto-label, training, counting). |
| `triage_rules` | 7 | `id` | Riwayat konfigurasi filter pencilan outlier dan rentang keep-range. |
| `annotation_overrides`| 6 | `id` | Keputusan override manual operator (`keep`/`ignore`) dari modul triage. |
@@ -851,6 +851,7 @@ Daftar endpoint REST API utama pada backend FastAPI:
| `POST` | `/api/projects/{id}/train` | `backend/training.py` | Memulai proses pelatihan model YOLO pada GPU worker queue. |
| `GET` | `/api/jobs/{id}/stream` | `backend/jobs.py` | Server-Sent Events (SSE) streaming log pelatihan real-time. |
| `POST` | `/api/models/{id}/promote` | `backend/models.py` | Mempromosikan versi model baru menjadi base model proyek. |
| `PATCH` | `/api/models/{id}/rename` | `backend/models.py` | Memperbarui nama deskriptif model (`{ name }`). |
| `GET` / `POST` | `/api/live-count/line` | `backend/live_count.py` | Mengambil atau memperbarui konfigurasi koordinat tripwire. |
| `POST` | `/api/counting-bench/run` | `backend/counting_bench.py`| Menjalankan evaluasi headless counting pada rekaman video arsip. |
+4 -2
View File
@@ -79,7 +79,7 @@ dataset_items( -- master dataset membership (REQ-052
split CHECK(train|val), image_rel, label_rel, added_at)
model_versions(
id, project_id → projects, version INT, weights_path,
id, project_id → projects, version INT, name TEXT, weights_path,
parent_model_path, metrics TEXT, base_metrics TEXT, created_at,
UNIQUE(project_id, version))
@@ -195,6 +195,7 @@ POST /api/projects/{id}/train # → train job (REQ-060,061,062
GET /api/projects/{id}/models # versions + metrics (REQ-063,064)
GET /api/models/{id}/weights # download best.pt
POST /api/models/{id}/promote # make it the project's base model (REQ-064)
PATCH /api/models/{id}/rename # update model display name { name }
GET /api/projects/{id}/live-count/models # weights this project can count with
POST /api/projects/{id}/live-count/start # {source|source_rel, model_path, dials}
@@ -258,7 +259,8 @@ Frames with no annotations produce an empty `.txt` (REQ-033).
**train (REQ-060…065).** Release the SAM3 engine → `YOLO(base/model.pt)` (or pretrained if
the project has no base yet) → `.train(data=dataset/data.yaml, **hardware.defaults())` →
`evaluate.py` runs `.val()` for both the base model and the new one against the same
`data.yaml` → store `models/<n>/best.pt` + `metrics.json`.
`data.yaml` → store `models/<n>/best.pt` + `metrics.json`. Each version is auto-named
`{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}` (e.g. `yolo11n-bbox-100ep-sack+box-20260909`).
`hardware.py` picks defaults from the detected VRAM:
+2 -1
View File
@@ -336,7 +336,8 @@ changes.
delta.
- **REQ-064** — Each training run produces a stored model version (weights + metrics). The
user can download the weights and **promote that version to be the project's new base
model** for the next round.
model** for the next round. Each version is auto-named
`{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}` and can be renamed by the user.
- **REQ-065** — SAM3 and training must never hold VRAM at the same time; the system releases
the SAM3 model before training starts.
+4 -4
View File
@@ -153,14 +153,14 @@ Serves REQ-060…065.
- `backend/hardware.py`: VRAM detection → `batch`/`imgsz`/`device` defaults.
- `backend/training.py`: release SAM3, fine-tune from `base/model.pt` on the master dataset,
store `models/<n>/`.
store `models/<n>/`, auto-name version `{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}`.
- `backend/evaluate.py`: `.val()` for the base model and the new one against the same
`data.yaml`; write `metrics.json`.
- Models page: train button, progress, base-vs-new table, download, *promote*.
- Models page: train button, progress, base-vs-new table, download (`{name}-best.pt`), *promote*.
**Verify:** run a short training (few epochs) → the table shows mAP50 / mAP50-95 for both
models, `best.pt` downloads, promoting the version swaps the project's base model and a
second training run starts from it.
models with a descriptive name, `best.pt` downloads as `{name}-best.pt`, promoting the version
swaps the project's base model and a second training run starts from it.
Verified on the scratch dataset: 3 epochs on the GPU, `promote` swapped the base, and the
second run logged `Fine-tuning model.pt`. The mAP figures are zero because those labels are
+7 -4
View File
@@ -685,18 +685,20 @@ only subtract from them.
- **Active job card** — `Training Job #id (status)`, `progress/total`, a bar, the error if any,
the **last 8 log lines** in a monospace scroll box, a Cancel button while running, and a green
"Training Finished" line when done (which also reloads the page data).
- **Trained Model Versions (N)** — one card per version: `v{n}`, the created timestamp, and a
metrics table:
- **Trained Model Versions (N)** — one card per version: the model name (auto-generated
`{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}`, clickable to rename inline),
the created timestamp, and a metrics table:
| Metric | Base | This version | Δ |
|---|---|---|---|
| mAP50, mAP50-95, precision, recall | 4 dp | 4 dp | signed, green if >0, red if <0 |
When there is no base column, an explicit line says the previous model could not be scored on
this val set. Actions: *Download best.pt*, **Use as base model** (`POST /models/{id}/promote`).
this val set. Actions: *Download best.pt* (`{name}-best.pt`), **Use as base model**
(`POST /models/{id}/promote`).
**Endpoints.** `/projects/{id}` , `/dataset`, `/datasets`, `/base-datasets`, `/models`,
`/hardware`, `/train`, `/models/{id}/promote`, `/models/{id}/weights`, `/jobs`.
`/hardware`, `/train`, `/models/{id}/promote`, `/models/{id}/rename`, `/models/{id}/weights`, `/jobs`.
---
@@ -1320,6 +1322,7 @@ GET /api/projects/{id}/export?batch_ids=&approved_only=&include_empty=
POST /api/projects/{id}/train { epochs, dataset_ids, base_dataset_ids, class_ids }
GET /api/projects/{id}/models
POST /api/models/{id}/promote
PATCH /api/models/{id}/rename { name }
GET /api/models/{id}/weights
```
+2
View File
@@ -229,6 +229,8 @@ export const api = {
request(`/projects/${projectId}/train`, { method: 'POST', body }),
listModels: (projectId) => request(`/projects/${projectId}/models`),
promoteModel: (modelId) => request(`/models/${modelId}/promote`, { method: 'POST' }),
renameModel: (modelId, name) =>
request(`/models/${modelId}/rename`, { method: 'PATCH', body: { name } }),
weightsUrl: (modelId) => `/api/models/${modelId}/weights`,
listJobs: (projectId) => request(`/jobs${projectId ? `?project_id=${projectId}` : ''}`),
+35 -1
View File
@@ -20,6 +20,8 @@ function Metric({ label, base, next, delta }) {
function VersionCard({ version, onPromote, onError }) {
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [nameValue, setNameValue] = useState(version.name || '')
const metrics = version.metrics
const base = version.base_metrics
@@ -35,10 +37,42 @@ function VersionCard({ version, onPromote, onError }) {
}
}
async function saveName() {
try {
await api.renameModel(version.id, nameValue)
setEditing(false)
onPromote()
} catch (exc) {
onError(exc.message)
}
}
function handleKeyDown(e) {
if (e.key === 'Enter') saveName()
if (e.key === 'Escape') { setNameValue(version.name || ''); setEditing(false) }
}
return (
<div className="panel side-panel">
<div className="row">
<h2>v{version.version}</h2>
{editing ? (
<input
autoFocus
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={saveName}
onKeyDown={handleKeyDown}
style={{ fontSize: '1rem', fontFamily: 'inherit', background: '#09090b', border: '1px solid rgba(192,132,252,0.5)', borderRadius: 4, color: '#fff', padding: '2px 6px', flex: 1 }}
/>
) : (
<h2
title="Click to rename"
onClick={() => setEditing(true)}
style={{ cursor: 'pointer', margin: 0 }}
>
{version.name || `v${version.version}`}
</h2>
)}
<span className="spacer" />
<span className="faint mono">
{new Date(version.created_at * 1000).toLocaleString()}