mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
Merge pull request #555 from maziggy/0.2.1.1
v0.2.1.1 Just one day ago after the last release, but I decided to make life a little better for those of you guys who are just received their new H2C :) What happened? Recent H2C deliveries from Bambu Lab ship with an updated internal model identifier: O1C2 instead of the previously known O1C. Bambuddy only recognized O1C, so these newer H2C units were not properly identified. Also included some fixes. - Fix sidebar navigation not respecting user permissions - Fix camera button permissions & ffmpeg process leak (#550) - Fix A1 Mini "unknown" status from non-UTF-8 MQTT payload (#549) - Fix Windows install syntax error from LF line endings (#544) - Fix queue badge showing on printers without matching filament (#486) - Fix naive-vs-aware datetime crash from 0.2.1 timezone migration
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Force CRLF line endings for Windows batch files so cmd.exe can parse them
|
||||
# regardless of the user's core.autocrlf setting.
|
||||
*.bat text eol=crlf
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.2.1.1] - 2026-02-28
|
||||
|
||||
|
||||
### Fixed
|
||||
- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
|
||||
- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
|
||||
- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
|
||||
- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
|
||||
- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
|
||||
- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
|
||||
|
||||
|
||||
## [0.2.1] - 2026-02-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -45,6 +45,10 @@ _stream_start_times: dict[int, float] = {}
|
||||
# Track active external camera streams by printer ID
|
||||
_active_external_streams: set[int] = set()
|
||||
|
||||
# Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)
|
||||
# Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
|
||||
_spawned_ffmpeg_pids: dict[int, float] = {}
|
||||
|
||||
|
||||
def get_buffered_frame(printer_id: int) -> bytes | None:
|
||||
"""Get the last buffered frame for a printer from an active stream.
|
||||
@@ -228,6 +232,9 @@ async def generate_rtsp_mjpeg_stream(
|
||||
# Track active process for cleanup
|
||||
if stream_id:
|
||||
_active_streams[stream_id] = process
|
||||
import time as _time
|
||||
|
||||
_spawned_ffmpeg_pids[process.pid] = _time.time()
|
||||
|
||||
# Give ffmpeg a moment to start and check for immediate failures
|
||||
await asyncio.sleep(0.5)
|
||||
@@ -344,6 +351,9 @@ async def generate_rtsp_mjpeg_stream(
|
||||
except OSError as e:
|
||||
logger.warning("Error terminating ffmpeg: %s", e)
|
||||
logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
|
||||
# Remove from PID tracking now that process is confirmed dead
|
||||
if process:
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/stream")
|
||||
@@ -444,6 +454,43 @@ async def camera_stream(
|
||||
|
||||
_stream_start_times[printer_id] = time.time()
|
||||
|
||||
async def _kill_stream_process(sid: str):
|
||||
"""Terminate+kill the ffmpeg process for a stream ID."""
|
||||
proc = _active_streams.get(sid)
|
||||
if proc and proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
async def _monitor_disconnect():
|
||||
"""Background task: poll for client disconnect independently of frame loop."""
|
||||
try:
|
||||
while not disconnect_event.is_set():
|
||||
await asyncio.sleep(2)
|
||||
if await request.is_disconnected():
|
||||
logger.info("Disconnect monitor: client gone (stream %s)", stream_id)
|
||||
disconnect_event.set()
|
||||
# Kill ffmpeg process (RTSP streams)
|
||||
await _kill_stream_process(stream_id)
|
||||
# Close chamber stream connection if applicable
|
||||
chamber = _active_chamber_streams.get(stream_id)
|
||||
if chamber:
|
||||
try:
|
||||
chamber[1].close()
|
||||
except OSError:
|
||||
pass
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
monitor_task = asyncio.create_task(_monitor_disconnect())
|
||||
|
||||
async def stream_with_disconnect_check():
|
||||
"""Wrapper generator that monitors for client disconnect."""
|
||||
try:
|
||||
@@ -457,7 +504,7 @@ async def camera_stream(
|
||||
printer_id=printer_id,
|
||||
):
|
||||
# Check if client is still connected
|
||||
if await request.is_disconnected():
|
||||
if disconnect_event.is_set() or await request.is_disconnected():
|
||||
logger.info("Client disconnected detected for stream %s", stream_id)
|
||||
disconnect_event.set()
|
||||
break
|
||||
@@ -470,6 +517,7 @@ async def camera_stream(
|
||||
disconnect_event.set()
|
||||
finally:
|
||||
disconnect_event.set()
|
||||
monitor_task.cancel()
|
||||
# Give a moment for the inner generator to clean up
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -504,10 +552,19 @@ async def stop_camera_stream(
|
||||
if process.returncode is None:
|
||||
try:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
|
||||
process.kill()
|
||||
await process.wait()
|
||||
stopped += 1
|
||||
logger.info("Terminated ffmpeg process for stream %s", stream_id)
|
||||
except ProcessLookupError:
|
||||
pass # Process already dead
|
||||
except OSError as e:
|
||||
logger.warning("Error stopping stream %s: %s", stream_id, e)
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
|
||||
for stream_id in to_remove:
|
||||
_active_streams.pop(stream_id, None)
|
||||
@@ -1086,3 +1143,109 @@ async def delete_reference(
|
||||
raise HTTPException(404, "Reference not found")
|
||||
|
||||
return {"success": True, "message": "Reference deleted"}
|
||||
|
||||
|
||||
def _scan_bambu_ffmpeg_pids() -> list[int]:
|
||||
"""Scan /proc for ffmpeg processes with Bambu RTSP URLs.
|
||||
|
||||
These are definitely ours — no other software connects to rtsps://bblp:.
|
||||
This catches orphans that survive app restarts and are not in any tracking dict.
|
||||
"""
|
||||
import os
|
||||
|
||||
pids = []
|
||||
try:
|
||||
for entry in os.listdir("/proc"):
|
||||
if not entry.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{entry}/cmdline", "rb") as f:
|
||||
cmdline = f.read()
|
||||
if b"ffmpeg" in cmdline and b"rtsps://bblp:" in cmdline:
|
||||
pids.append(int(entry))
|
||||
except (OSError, PermissionError, ValueError):
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return pids
|
||||
|
||||
|
||||
async def cleanup_orphaned_streams():
|
||||
"""Clean up orphaned ffmpeg processes and stale stream entries.
|
||||
|
||||
Called periodically from the background task loop in main.py.
|
||||
|
||||
Three-layer cleanup:
|
||||
1. /proc scan — finds ALL Bambu ffmpeg processes on the system, even those
|
||||
from previous app sessions. This is the nuclear safety net.
|
||||
2. _spawned_ffmpeg_pids — tracks PIDs spawned this session, catches orphans
|
||||
that were removed from _active_streams but not killed.
|
||||
3. _active_streams — kills stale entries with no recent frames.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
cleaned = 0
|
||||
now = time.time()
|
||||
|
||||
# Collect PIDs that are legitimately in-use (active stream, process alive)
|
||||
active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
|
||||
|
||||
# 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
|
||||
# Any ffmpeg with rtsps://bblp: that is NOT in an active stream is orphaned.
|
||||
for pid in _scan_bambu_ffmpeg_pids():
|
||||
if pid in active_pids:
|
||||
continue
|
||||
logger.info("Killing orphaned ffmpeg process found via /proc (pid=%d)", pid)
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
_spawned_ffmpeg_pids.pop(pid, None)
|
||||
cleaned += 1
|
||||
|
||||
# 2. Clean up _spawned_ffmpeg_pids entries for dead processes
|
||||
for pid in list(_spawned_ffmpeg_pids):
|
||||
try:
|
||||
os.kill(pid, 0) # existence check
|
||||
except (ProcessLookupError, OSError):
|
||||
_spawned_ffmpeg_pids.pop(pid, None)
|
||||
|
||||
# 3. Clean up _active_streams entries with dead processes
|
||||
dead_streams = [sid for sid, proc in _active_streams.items() if proc.returncode is not None]
|
||||
for sid in dead_streams:
|
||||
proc = _active_streams.pop(sid, None)
|
||||
if proc:
|
||||
_spawned_ffmpeg_pids.pop(proc.pid, None)
|
||||
cleaned += 1
|
||||
|
||||
# 4. Kill stale active streams (alive but no frames for >60s)
|
||||
for sid, proc in list(_active_streams.items()):
|
||||
if proc.returncode is not None:
|
||||
continue
|
||||
try:
|
||||
printer_id = int(sid.split("-", 1)[0])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
start_time = _stream_start_times.get(printer_id, now)
|
||||
last_frame = _last_frame_times.get(printer_id, start_time)
|
||||
if now - start_time > 120 and now - last_frame > 60:
|
||||
logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - last_frame)
|
||||
try:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
_active_streams.pop(sid, None)
|
||||
_spawned_ffmpeg_pids.pop(proc.pid, None)
|
||||
cleaned += 1
|
||||
|
||||
# 4. Clean stale chamber stream entries
|
||||
dead_chamber = [sid for sid, (_reader, writer) in _active_chamber_streams.items() if writer.is_closing()]
|
||||
for sid in dead_chamber:
|
||||
_active_chamber_streams.pop(sid, None)
|
||||
cleaned += 1
|
||||
|
||||
if cleaned:
|
||||
logger.info("Cleaned up %d orphaned camera stream(s)", cleaned)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "0.2.1"
|
||||
APP_VERSION = "0.2.1.1"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
|
||||
# App directory - where the application is installed (for static files)
|
||||
|
||||
@@ -3217,6 +3217,40 @@ def stop_spoolbuddy_watchdog():
|
||||
logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
|
||||
|
||||
|
||||
# Camera stream orphan cleanup
|
||||
_camera_cleanup_task: asyncio.Task | None = None
|
||||
CAMERA_CLEANUP_INTERVAL = 60
|
||||
|
||||
|
||||
async def _camera_cleanup_loop():
|
||||
"""Periodically clean up orphaned ffmpeg processes."""
|
||||
from backend.app.api.routes.camera import cleanup_orphaned_streams
|
||||
|
||||
while True:
|
||||
try:
|
||||
await cleanup_orphaned_streams()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning("Camera stream cleanup failed: %s", e)
|
||||
await asyncio.sleep(CAMERA_CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
def start_camera_cleanup():
|
||||
global _camera_cleanup_task
|
||||
if _camera_cleanup_task is None:
|
||||
_camera_cleanup_task = asyncio.create_task(_camera_cleanup_loop())
|
||||
logging.getLogger(__name__).info("Camera stream cleanup started")
|
||||
|
||||
|
||||
def stop_camera_cleanup():
|
||||
global _camera_cleanup_task
|
||||
if _camera_cleanup_task:
|
||||
_camera_cleanup_task.cancel()
|
||||
_camera_cleanup_task = None
|
||||
logging.getLogger(__name__).info("Camera stream cleanup stopped")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
@@ -3326,6 +3360,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start SpoolBuddy device watchdog
|
||||
start_spoolbuddy_watchdog()
|
||||
|
||||
# Start camera stream orphan cleanup
|
||||
start_camera_cleanup()
|
||||
|
||||
# Initialize virtual printer manager and sync from DB
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
@@ -3347,6 +3384,7 @@ async def lifespan(app: FastAPI):
|
||||
stop_ams_history_recording()
|
||||
stop_runtime_tracking()
|
||||
stop_spoolbuddy_watchdog()
|
||||
stop_camera_cleanup()
|
||||
printer_manager.disconnect_all()
|
||||
await close_spoolman_client()
|
||||
|
||||
|
||||
@@ -442,7 +442,19 @@ class BambuMQTTClient:
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode())
|
||||
try:
|
||||
raw = msg.payload.decode()
|
||||
except UnicodeDecodeError:
|
||||
# Some firmware versions (e.g. A1 Mini 01.07.02.00) send payloads
|
||||
# with non-UTF-8 bytes. Replace invalid bytes to keep JSON parseable.
|
||||
raw = msg.payload.decode(errors="replace")
|
||||
logger.warning(
|
||||
"[%s] MQTT payload contained non-UTF-8 bytes (topic=%s, len=%d)",
|
||||
self.serial_number,
|
||||
msg.topic,
|
||||
len(msg.payload),
|
||||
)
|
||||
payload = json.loads(raw)
|
||||
# Track last message time - receiving a message proves we're connected
|
||||
self._last_message_time = time.time()
|
||||
self.state.connected = True
|
||||
|
||||
@@ -72,7 +72,7 @@ def supports_rtsp(model: str | None) -> bool:
|
||||
- BL-P001: X1/X1C
|
||||
- C13: X1E
|
||||
- O1D: H2D
|
||||
- O1C: H2C
|
||||
- O1C, O1C2: H2C
|
||||
- O1S: H2S
|
||||
- O1E, O2D: H2D Pro
|
||||
- N7: P2S
|
||||
@@ -83,7 +83,7 @@ def supports_rtsp(model: str | None) -> bool:
|
||||
if model_upper.startswith(("X1", "H2", "P2")):
|
||||
return True
|
||||
# Internal codes for RTSP models
|
||||
if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1S", "O1E", "O2D", "N7"):
|
||||
if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
|
||||
return True
|
||||
# A1/P1 and unknown models use chamber image protocol
|
||||
return False
|
||||
|
||||
@@ -31,6 +31,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
|
||||
"C13", # X1E
|
||||
"O1D", # H2D
|
||||
"O1C", # H2C
|
||||
"O1C2", # H2C (dual nozzle variant)
|
||||
"O1S", # H2S
|
||||
"O1E", # H2D Pro
|
||||
"O2D", # H2D Pro (alternate code)
|
||||
|
||||
@@ -41,6 +41,7 @@ VIRTUAL_PRINTER_MODELS = {
|
||||
# H2 Series
|
||||
"O1D": "H2D", # H2D
|
||||
"O1C": "H2C", # H2C
|
||||
"O1C2": "H2C", # H2C (dual nozzle variant)
|
||||
"O1S": "H2S", # H2S
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ MODEL_SERIAL_PREFIXES = {
|
||||
# H2 Series
|
||||
"O1D": "09400A", # H2D
|
||||
"O1C": "09400A", # H2C
|
||||
"O1C2": "09400A", # H2C (dual nozzle variant)
|
||||
"O1S": "09400A", # H2S
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ MODEL_PRODUCT_NAMES = {
|
||||
"N1": "A1 mini",
|
||||
"O1D": "H2D",
|
||||
"O1C": "H2C",
|
||||
"O1C2": "H2C",
|
||||
"O1S": "H2S",
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ PRINTER_MODEL_ID_MAP = {
|
||||
"O1E": "H2D Pro", # Some devices report O1E
|
||||
"O2D": "H2D Pro", # Some devices report O2D
|
||||
"O1C": "H2C",
|
||||
"O1C2": "H2C",
|
||||
"O1S": "H2S",
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ LINEAR_RAIL_MODELS = frozenset(
|
||||
"O1E", # H2D Pro
|
||||
"O2D", # H2D Pro (alternate)
|
||||
"O1C", # H2C
|
||||
"O1C2", # H2C (dual nozzle variant)
|
||||
"O1S", # H2S
|
||||
]
|
||||
)
|
||||
|
||||
@@ -59,10 +59,12 @@ class TestCameraAPI:
|
||||
"""Verify stop terminates active streams for the printer."""
|
||||
printer = await printer_factory()
|
||||
|
||||
# Mock an active stream
|
||||
# Mock an active stream — wait() must be AsyncMock since it's awaited
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = None
|
||||
mock_process.pid = 99999
|
||||
mock_process.terminate = MagicMock()
|
||||
mock_process.wait = AsyncMock()
|
||||
|
||||
with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}):
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
|
||||
@@ -78,14 +80,18 @@ class TestCameraAPI:
|
||||
printer1 = await printer_factory(name="Printer 1")
|
||||
printer2 = await printer_factory(name="Printer 2")
|
||||
|
||||
# Mock active streams for both printers
|
||||
# Mock active streams for both printers — wait() must be AsyncMock since it's awaited
|
||||
mock_process1 = MagicMock()
|
||||
mock_process1.returncode = None
|
||||
mock_process1.pid = 99998
|
||||
mock_process1.terminate = MagicMock()
|
||||
mock_process1.wait = AsyncMock()
|
||||
|
||||
mock_process2 = MagicMock()
|
||||
mock_process2.returncode = None
|
||||
mock_process2.pid = 99997
|
||||
mock_process2.terminate = MagicMock()
|
||||
mock_process2.wait = AsyncMock()
|
||||
|
||||
active_streams = {
|
||||
f"{printer1.id}-abc123": mock_process1,
|
||||
|
||||
@@ -1998,6 +1998,7 @@ export type Permission =
|
||||
| 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
|
||||
| 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
|
||||
| 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
|
||||
| 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete'
|
||||
| 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
|
||||
| 'camera:view'
|
||||
| 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
import { SwitchbarPopover } from './SwitchbarPopover';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { api, supportApi, pendingUploadsApi } from '../api/client';
|
||||
import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
|
||||
import { getIconByName } from './IconPicker';
|
||||
import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -216,16 +216,30 @@ export function Layout() {
|
||||
const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
|
||||
|
||||
// Compute the ordered sidebar: include stored order + any new items
|
||||
// Filter out 'settings' for users with 'user' role
|
||||
// Hide nav items the user doesn't have read permission for
|
||||
const orderedSidebarIds = (() => {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Determine if settings should be hidden (no settings:read permission)
|
||||
const hideSettings = authEnabled && !hasPermission('settings:read');
|
||||
// Map nav item IDs to the permission required to see them
|
||||
const navPermissions: Record<string, Permission> = {
|
||||
archives: 'archives:read',
|
||||
queue: 'queue:read',
|
||||
stats: 'stats:read',
|
||||
profiles: 'kprofiles:read',
|
||||
maintenance: 'maintenance:read',
|
||||
projects: 'projects:read',
|
||||
inventory: 'inventory:read',
|
||||
files: 'library:read',
|
||||
settings: 'settings:read',
|
||||
};
|
||||
|
||||
const isHidden = (id: string) =>
|
||||
authEnabled && id in navPermissions && !hasPermission(navPermissions[id]);
|
||||
|
||||
// Add items in stored order
|
||||
for (const id of sidebarOrder) {
|
||||
if (hideSettings && id === 'settings') continue;
|
||||
if (isHidden(id)) continue;
|
||||
if (navItemsMap.has(id) || extLinksMap.has(id)) {
|
||||
result.push(id);
|
||||
seen.add(id);
|
||||
@@ -234,7 +248,7 @@ export function Layout() {
|
||||
|
||||
// Add any new internal nav items not in stored order
|
||||
for (const item of defaultNavItems) {
|
||||
if (hideSettings && item.id === 'settings') continue;
|
||||
if (isHidden(item.id)) continue;
|
||||
if (!seen.has(item.id)) {
|
||||
result.push(item.id);
|
||||
seen.add(item.id);
|
||||
|
||||
@@ -305,6 +305,7 @@ export default {
|
||||
noFiles: 'Sie haben keine Berechtigung, auf Druckerdateien zuzugreifen',
|
||||
noAmsRfid: 'Sie haben keine Berechtigung, AMS-RFID erneut zu lesen',
|
||||
noSmartPlugControl: 'Sie haben keine Berechtigung, Smart Plugs zu steuern',
|
||||
noCamera: 'Sie haben keine Berechtigung, Kameras anzuzeigen',
|
||||
},
|
||||
// Add/Edit modal
|
||||
modal: {
|
||||
|
||||
@@ -305,6 +305,7 @@ export default {
|
||||
noFiles: 'You do not have permission to access printer files',
|
||||
noAmsRfid: 'You do not have permission to re-read AMS RFID',
|
||||
noSmartPlugControl: 'You do not have permission to control smart plugs',
|
||||
noCamera: 'You do not have permission to view cameras',
|
||||
},
|
||||
// Add/Edit modal
|
||||
modal: {
|
||||
|
||||
@@ -305,6 +305,7 @@ export default {
|
||||
noFiles: 'Pas d\'autorisation pour les fichiers',
|
||||
noAmsRfid: 'Pas d\'autorisation pour le RFID',
|
||||
noSmartPlugControl: 'Pas d\'autorisation pour les prises',
|
||||
noCamera: 'Pas d\'autorisation pour les caméras',
|
||||
},
|
||||
// Add/Edit modal
|
||||
modal: {
|
||||
|
||||
@@ -296,6 +296,7 @@ export default {
|
||||
noFiles: 'Non hai il permesso di accedere ai file stampante',
|
||||
noAmsRfid: 'Non hai il permesso di rileggere AMS RFID',
|
||||
noSmartPlugControl: 'Non hai il permesso di controllare smart plug',
|
||||
noCamera: 'Non hai il permesso di visualizzare le telecamere',
|
||||
},
|
||||
// Add/Edit modal
|
||||
modal: {
|
||||
|
||||
@@ -269,6 +269,7 @@ export default {
|
||||
noControl: 'プリンターを制御する権限がありません',
|
||||
noAmsRfid: 'AMS RFIDを再読み取りする権限がありません',
|
||||
noSmartPlugControl: 'スマートプラグを制御する権限がありません',
|
||||
noCamera: 'カメラを表示する権限がありません',
|
||||
},
|
||||
modal: {
|
||||
selectModel: 'モデルを選択...',
|
||||
|
||||
@@ -305,6 +305,7 @@ export default {
|
||||
noFiles: 'Você não tem permissão para acessar arquivos de impressora',
|
||||
noAmsRfid: 'Você não tem permissão para reler RFID AMS',
|
||||
noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',
|
||||
noCamera: 'Você não tem permissão para visualizar câmeras',
|
||||
},
|
||||
// Add/Edit modal
|
||||
modal: {
|
||||
|
||||
@@ -1363,6 +1363,7 @@ function mapModelCode(ssdpModel: string | null): string {
|
||||
'O1E': 'H2D Pro',
|
||||
'O2D': 'H2D Pro',
|
||||
'O1C': 'H2C',
|
||||
'O1C2': 'H2C',
|
||||
'O1S': 'H2S',
|
||||
// X1 Series
|
||||
'BL-P001': 'X1C',
|
||||
@@ -1672,7 +1673,27 @@ function PrinterCard({
|
||||
queryKey: ['queue', printer.id, 'pending'],
|
||||
queryFn: () => api.getQueue(printer.id, 'pending'),
|
||||
});
|
||||
const queueCount = queueItems?.length || 0;
|
||||
// Filter queue items by filament compatibility (same logic as PrinterQueueWidget)
|
||||
// so the badge only shows on printers that can actually run the queued jobs.
|
||||
const queueCount = useMemo(() => {
|
||||
if (!queueItems?.length) return 0;
|
||||
return queueItems.filter(item => {
|
||||
if (item.required_filament_types?.length && loadedFilamentTypes?.size) {
|
||||
if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (item.filament_overrides?.length && loadedFilaments?.size) {
|
||||
const hasColorMatch = item.filament_overrides.some((o: { type?: string; color?: string }) => {
|
||||
const oType = (o.type || '').toUpperCase();
|
||||
const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
|
||||
return loadedFilaments.has(`${oType}:${oColor}`);
|
||||
});
|
||||
if (!hasColorMatch) return false;
|
||||
}
|
||||
return true;
|
||||
}).length;
|
||||
}, [queueItems, loadedFilamentTypes, loadedFilaments]);
|
||||
|
||||
// Fetch currently printing queue item to show who started it (Issue #206)
|
||||
const { data: printingQueueItems } = useQuery({
|
||||
@@ -3589,8 +3610,8 @@ function PrinterCard({
|
||||
window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
|
||||
}
|
||||
}}
|
||||
disabled={!status?.connected}
|
||||
title={cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow')}
|
||||
disabled={!status?.connected || !hasPermission('camera:view')}
|
||||
title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CxFtC4Kb.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D35pKbMC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJjXosw8.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user