diff --git a/TODO.md b/TODO.md
index 3ed8420..050a4a5 100644
--- a/TODO.md
+++ b/TODO.md
@@ -59,3 +59,5 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
- Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration
- Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming")
- [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
+- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
+- [ ] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards
diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py
index 1df32ca..9767d75 100644
--- a/server/src/wled_controller/api/routes/scene_presets.py
+++ b/server/src/wled_controller/api/routes/scene_presets.py
@@ -164,6 +164,28 @@ async def delete_scene_preset(
raise HTTPException(status_code=404, detail=str(e))
+# ===== Clone =====
+
+@router.post(
+ "/api/v1/scene-presets/{preset_id}/clone",
+ response_model=ScenePresetResponse,
+ tags=["Scene Presets"],
+ status_code=201,
+)
+async def clone_scene_preset(
+ preset_id: str,
+ _auth: AuthRequired,
+ store: ScenePresetStore = Depends(get_scene_preset_store),
+):
+ """Duplicate a scene preset with all its stored target snapshots."""
+ try:
+ cloned = store.clone_preset(preset_id)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+
+ return _preset_to_response(cloned)
+
+
# ===== Recapture =====
@router.post(
diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js
index f1865e4..67aaa90 100644
--- a/server/src/wled_controller/static/js/app.js
+++ b/server/src/wled_controller/static/js/app.js
@@ -85,7 +85,7 @@ import {
} from './features/automations.js';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
- activateScenePreset, recaptureScenePreset, deleteScenePreset,
+ activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
addSceneTarget, removeSceneTarget,
} from './features/scene-presets.js';
@@ -325,6 +325,7 @@ Object.assign(window, {
closeScenePresetEditor,
activateScenePreset,
recaptureScenePreset,
+ cloneScenePreset,
deleteScenePreset,
addSceneTarget,
removeSceneTarget,
diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js
index e9a9926..1a20289 100644
--- a/server/src/wled_controller/static/js/features/scene-presets.js
+++ b/server/src/wled_controller/static/js/features/scene-presets.js
@@ -9,7 +9,7 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import {
- ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET,
+ ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
} from '../core/icons.js';
import { scenePresetsCache } from '../core/state.js';
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
@@ -61,6 +61,7 @@ export function createSceneCard(preset) {
${updated ? `${updated}` : ''}
+
@@ -304,6 +305,26 @@ export async function recaptureScenePreset(presetId) {
}
}
+// ===== Clone =====
+
+export async function cloneScenePreset(presetId) {
+ try {
+ const resp = await fetchWithAuth(`/scene-presets/${presetId}/clone`, {
+ method: 'POST',
+ });
+ if (resp.ok) {
+ showToast(t('scenes.cloned'), 'success');
+ _reloadScenesTab();
+ } else {
+ const err = await resp.json().catch(() => ({}));
+ showToast(err.detail || t('scenes.error.clone_failed'), 'error');
+ }
+ } catch (error) {
+ if (error.isAuth) return;
+ showToast(t('scenes.error.clone_failed'), 'error');
+ }
+}
+
// ===== Delete =====
export async function deleteScenePreset(presetId) {
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 1b5dfbf..a26b307 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -681,6 +681,8 @@
"scenes.error.activate_failed": "Failed to activate scene",
"scenes.error.recapture_failed": "Failed to recapture scene",
"scenes.error.delete_failed": "Failed to delete scene",
+ "scenes.cloned": "Scene cloned",
+ "scenes.error.clone_failed": "Failed to clone scene",
"time.hours_minutes": "{h}h {m}m",
"time.minutes_seconds": "{m}m {s}s",
"time.seconds": "{s}s",
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index cb459cf..97d4814 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -681,6 +681,8 @@
"scenes.error.activate_failed": "Не удалось активировать сцену",
"scenes.error.recapture_failed": "Не удалось перезахватить сцену",
"scenes.error.delete_failed": "Не удалось удалить сцену",
+ "scenes.cloned": "Сцена клонирована",
+ "scenes.error.clone_failed": "Не удалось клонировать сцену",
"time.hours_minutes": "{h}ч {m}м",
"time.minutes_seconds": "{m}м {s}с",
"time.seconds": "{s}с",
diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json
index 9e76d21..c20f44f 100644
--- a/server/src/wled_controller/static/locales/zh.json
+++ b/server/src/wled_controller/static/locales/zh.json
@@ -681,6 +681,8 @@
"scenes.error.activate_failed": "激活场景失败",
"scenes.error.recapture_failed": "重新捕获场景失败",
"scenes.error.delete_failed": "删除场景失败",
+ "scenes.cloned": "场景已克隆",
+ "scenes.error.clone_failed": "克隆场景失败",
"time.hours_minutes": "{h}时 {m}分",
"time.minutes_seconds": "{m}分 {s}秒",
"time.seconds": "{s}秒",
diff --git a/server/src/wled_controller/storage/scene_preset_store.py b/server/src/wled_controller/storage/scene_preset_store.py
index eb46d74..ac80868 100644
--- a/server/src/wled_controller/storage/scene_preset_store.py
+++ b/server/src/wled_controller/storage/scene_preset_store.py
@@ -6,7 +6,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
-from wled_controller.storage.scene_preset import ScenePreset
+from wled_controller.storage.scene_preset import ScenePreset, TargetSnapshot
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
@@ -117,6 +117,25 @@ class ScenePresetStore:
logger.info(f"Recaptured scene preset: {preset_id}")
return existing
+ def clone_preset(self, preset_id: str) -> ScenePreset:
+ """Duplicate an existing preset with a new ID and '(Copy)' suffix."""
+ if preset_id not in self._presets:
+ raise ValueError(f"Scene preset not found: {preset_id}")
+
+ source = self._presets[preset_id]
+ new_id = f"scene_{uuid.uuid4().hex[:8]}"
+ cloned = ScenePreset(
+ id=new_id,
+ name=f"{source.name} (Copy)",
+ description=source.description,
+ targets=[TargetSnapshot.from_dict(t.to_dict()) for t in source.targets],
+ order=source.order,
+ )
+ self._presets[new_id] = cloned
+ self._save()
+ logger.info(f"Cloned scene preset: {preset_id} -> {new_id}")
+ return cloned
+
def delete_preset(self, preset_id: str) -> None:
if preset_id not in self._presets:
raise ValueError(f"Scene preset not found: {preset_id}")