From e1b0cae83b4f4d99bec470c01cb0b619517d2bf0 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Wed, 25 Mar 2026 15:52:03 +0100
Subject: [PATCH 01/20] docs: add serial command execution plan
---
plans/serial-command-execution-plan.md | 297 +++++++++++++++++++++++++
1 file changed, 297 insertions(+)
create mode 100644 plans/serial-command-execution-plan.md
diff --git a/plans/serial-command-execution-plan.md b/plans/serial-command-execution-plan.md
new file mode 100644
index 0000000..9ac88b0
--- /dev/null
+++ b/plans/serial-command-execution-plan.md
@@ -0,0 +1,297 @@
+# Serial-Preferred Command Execution Plan
+
+## Goal
+
+Extend the dashboard so command execution prefers a serial shell and falls back to SSH only when serial execution is not available for the target.
+
+The feature must apply to:
+
+- manual commands
+- scheduled commands
+- REST and WebSocket execution paths
+
+It must also expose per-target command capability so the frontend can hide command buttons and show a device-specific message when no supported execution transport is available.
+
+## Current Constraints
+
+- Command execution is currently hardcoded to `labgrid-client ssh` in `backend/app/services/labgrid_client.py`.
+- Both REST and WebSocket command paths use the same backend execution method, so transport selection can be centralized there.
+- The API currently exposes resources and status, but not command capability or chosen execution transport.
+- The frontend therefore cannot distinguish between:
+ - command-capable targets
+ - targets that only support SSH
+ - targets that only support serial
+ - targets that support neither
+- LXATAC targets can be partially available even when some optional USB-related resources are offline, so transport selection must rely on actual execution capabilities instead of whole-target availability alone.
+
+## Required Feature Behavior
+
+### Execution Order
+
+The execution transport order must be:
+
+1. serial
+2. ssh
+3. unsupported
+
+### Preset-Level Configuration
+
+Each preset must be able to define how command execution should work.
+
+The minimum planned configuration is:
+
+- ordered transport preference per preset
+- serial login automation settings per preset
+- optional shell readiness settings per preset
+
+Planned `commands.yaml` extension:
+
+```yaml
+default_preset: TAC
+
+presets:
+ TAC:
+ name: "TAC"
+ description: "TAC devices"
+
+ command_execution:
+ transport_order:
+ - serial
+ - ssh
+ serial:
+ login:
+ enabled: true
+ username: "root"
+ password: "labgrid"
+ prompt:
+ pattern: "root@.*[#>] "
+ login_timeout_seconds: 30
+ command_timeout_seconds: 60
+
+ commands:
+ - name: "OS Release"
+ command: "cat /etc/os-release"
+ description: "Show OS release information"
+```
+
+This schema is intentionally explicit so the backend does not have to guess how to log in over serial.
+
+## Proposed Backend Design
+
+### 1. Add Execution Capability Detection
+
+Determine execution capability per target from matched resources and preset configuration.
+
+Planned backend concepts:
+
+- `serial` capability:
+ - target has an available serial-capable resource
+ - preset permits serial execution
+ - serial login/shell settings are available if required
+- `ssh` capability:
+ - target has an available SSH-capable resource
+ - preset permits SSH execution
+- `none` capability:
+ - neither serial nor SSH can be used safely
+
+Planned output per target:
+
+- `execution_transport`: `serial` | `ssh` | `none`
+- `command_capable`: `true` | `false`
+- `command_capability_error`: nullable string with a user-facing explanation
+
+### 2. Refactor Command Execution Into Transport Selectors
+
+Refactor the current backend method into:
+
+- transport selection
+- transport-specific execution
+- shared acquire/release handling
+
+Planned structure inside `LabgridClient`:
+
+- `_select_execution_transport(place_name, preset_execution_config)`
+- `_execute_via_ssh(place_name, command)`
+- `_execute_via_serial(place_name, command, serial_config)`
+- shared `execute_command(...)` wrapper that:
+ - acquires target
+ - selects transport
+ - runs command
+ - releases target
+
+### 3. Keep HTTP and WebSocket Behavior Unified
+
+REST and WebSocket paths should continue using the same backend execution API.
+
+That keeps:
+
+- identical transport behavior
+- identical error handling
+- identical state updates
+
+### 4. Add Target Capability Information To API Models
+
+The frontend needs capability data from the backend instead of inferring it from resource names.
+
+Planned API model additions:
+
+- `command_capable`
+- `execution_transport`
+- `command_capability_error`
+
+These fields should be present in:
+
+- `GET /api/targets`
+- `GET /api/targets/{name}`
+- WebSocket target updates
+- WebSocket full target list payloads
+
+## Serial Execution Strategy
+
+### Preferred Implementation Direction
+
+Use a dedicated backend serial execution path, not a frontend workaround.
+
+The current SSH implementation works by shelling out to `labgrid-client ssh`.
+For serial, there are two possible approaches:
+
+1. CLI-driven approach
+ - use `labgrid-client console`
+ - automate login and command execution via a PTY session
+
+2. Python/labgrid driver approach
+ - use labgrid driver APIs directly
+ - activate a shell-capable serial driver
+ - run commands through the driver
+
+The recommended implementation path is:
+
+- first validate whether the production runtime can reliably automate `labgrid-client console`
+- if that proves too fragile, switch to direct Python/labgrid integration for serial
+
+### Why Serial Needs More Than `NetworkSerialPort`
+
+`NetworkSerialPort` alone does not guarantee command execution.
+
+Robust serial command execution also needs:
+
+- login prompt detection
+- username/password automation
+- shell prompt detection
+- command completion handling
+- timeout handling
+- reconnect behavior when the line is noisy or the DUT reboots
+
+## Frontend Changes
+
+### Target Table
+
+Update the target row rendering so command controls depend on backend-provided capability instead of only target status.
+
+Planned behavior:
+
+- if `command_capable` is `true`:
+ - show normal command UI
+- if `command_capable` is `false`:
+ - hide command buttons
+ - show target-specific explanation from `command_capability_error`
+
+### Optional UI Enhancement
+
+Optionally show the selected execution transport in the UI:
+
+- `Serial`
+- `SSH`
+- `Unavailable`
+
+This is not required for the first implementation, but it would simplify debugging.
+
+## Tests To Add Or Update
+
+### Backend Unit Tests
+
+Add tests for:
+
+- transport selection prefers serial over SSH
+- serial fallback to SSH when serial is unavailable
+- `none` transport returns a clear error
+- target capability fields are populated correctly
+- per-preset transport configuration is respected
+- serial login automation configuration validation
+
+### API Tests
+
+Add tests for:
+
+- `GET /api/targets` includes capability fields
+- `GET /api/targets/{name}` includes capability fields
+- unsupported targets return the expected error on command execution
+
+### Integration Tests
+
+Extend staging or add a focused integration test path that validates:
+
+- serial-preferred transport selection
+- scheduled commands use the same transport order
+
+## Files Expected To Change
+
+Backend:
+
+- `backend/app/services/labgrid_client.py`
+- `backend/app/services/command_service.py`
+- `backend/app/models/target.py`
+- `backend/app/api/routes/targets.py`
+- `backend/app/api/websocket.py`
+- `backend/app/config.py` if new runtime settings are required
+
+Frontend:
+
+- `frontend/src/types/index.ts`
+- `frontend/src/hooks/usePresetsWithTargets.ts`
+- `frontend/src/components/TargetTable/TargetRow.tsx`
+- `frontend/src/components/CommandPanel/*` if command capability display is needed
+
+Tests:
+
+- `backend/tests/test_labgrid_client.py`
+- `backend/tests/test_targets.py`
+- `backend/tests/test_acquire_release.py`
+- frontend tests for command visibility
+
+Planning/Documentation:
+
+- `plans/serial-command-execution-plan.md`
+
+## Implementation Order
+
+1. Extend preset configuration model for transport order and serial login settings
+2. Add capability detection and target capability fields
+3. Refactor SSH execution into a dedicated transport executor
+4. Implement serial executor
+5. Update REST and WebSocket payloads
+6. Update frontend to hide commands and show per-device capability errors
+7. Add tests
+8. Validate with staging and production image
+
+## Open Questions
+
+The following items must be confirmed before the implementation is finalized:
+
+1. What exact `commands.yaml` schema should be used for per-preset execution config?
+2. Should serial login credentials be stored in `commands.yaml`, in environment variables, or in a separate secret-backed config source?
+3. What prompt patterns must be supported for serial login and shell readiness?
+4. Should scheduled commands use the same transport selection logic without exception?
+5. Should unsupported targets suppress the entire command panel or show a reduced read-only panel with the explanation?
+6. Is `labgrid-client console` automation acceptable as the first implementation, or must serial execution use the Python labgrid driver API from the start?
+
+## Success Criteria
+
+The feature is complete when:
+
+- LXATAC targets prefer serial command execution
+- SSH is used only when serial execution is unavailable for the target and preset
+- unsupported targets hide command actions and show a clear reason
+- scheduled commands use the same transport order
+- the backend and frontend expose the selected transport clearly enough for debugging
+- the behavior is covered by tests and passes local verification
From b64bd0415a451788f9fc070bff77f3be50b33492 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Wed, 25 Mar 2026 15:52:07 +0100
Subject: [PATCH 02/20] feat(backend): add preset-aware serial command
execution
---
backend/app/api/routes/targets.py | 90 +++-
backend/app/api/websocket.py | 125 +++--
backend/app/main.py | 26 +-
backend/app/models/target.py | 121 +++++
backend/app/services/__init__.py | 3 +-
.../app/services/command_execution_service.py | 500 ++++++++++++++++++
backend/app/services/command_service.py | 45 ++
backend/app/services/labgrid_client.py | 17 +-
backend/commands.yaml | 8 +
backend/tests/conftest.py | 15 +-
backend/tests/test_command_service_presets.py | 28 +
backend/tests/test_main.py | 161 ++++++
backend/tests/test_targets.py | 55 +-
13 files changed, 1136 insertions(+), 58 deletions(-)
create mode 100644 backend/app/services/command_execution_service.py
diff --git a/backend/app/api/routes/targets.py b/backend/app/api/routes/targets.py
index b2516f4..ed341dd 100644
--- a/backend/app/api/routes/targets.py
+++ b/backend/app/api/routes/targets.py
@@ -26,6 +26,7 @@
Target,
)
from app.services.command_service import CommandService
+from app.services.command_execution_service import CommandExecutionService
from app.services.labgrid_client import (
LabgridClient,
TargetAcquiredByOtherError,
@@ -43,6 +44,7 @@
_command_service: CommandService | None = None
_scheduler_service: SchedulerService | None = None
_preset_service: PresetService | None = None
+_command_execution_service: CommandExecutionService | None = None
def set_labgrid_client(client: LabgridClient) -> None:
@@ -69,6 +71,14 @@ def set_preset_service(service: PresetService) -> None:
_preset_service = service
+def set_command_execution_service(
+ service: CommandExecutionService | None,
+) -> None:
+ """Set the global command execution service instance."""
+ global _command_execution_service
+ _command_execution_service = service
+
+
def get_labgrid_client() -> LabgridClient:
"""Dependency to get the Labgrid client."""
if _labgrid_client is None:
@@ -109,6 +119,37 @@ def get_preset_service() -> PresetService:
return _preset_service
+def _enrich_target(
+ target: Target,
+ *,
+ scheduler: SchedulerService | None = None,
+) -> Target:
+ """Add command capability and scheduled output data to a target."""
+ if _command_execution_service is not None:
+ target = _command_execution_service.enrich_target(target)
+
+ if scheduler is not None:
+ target.scheduled_outputs = scheduler.get_outputs_for_target(target.name)
+
+ return target
+
+
+def _enrich_targets(
+ targets: List[Target],
+ *,
+ scheduler: SchedulerService | None = None,
+) -> List[Target]:
+ """Add command capability and scheduled output data to a target list."""
+ if _command_execution_service is not None:
+ targets = _command_execution_service.enrich_targets(targets)
+
+ if scheduler is not None:
+ for target in targets:
+ target.scheduled_outputs = scheduler.get_outputs_for_target(target.name)
+
+ return targets
+
+
@router.get(
"",
response_model=TargetListResponse,
@@ -121,10 +162,7 @@ async def get_targets(
) -> TargetListResponse:
"""Get all targets from the Labgrid coordinator with scheduled command outputs."""
targets = await client.get_places()
-
- # Enrich targets with scheduled command outputs
- for target in targets:
- target.scheduled_outputs = scheduler.get_outputs_for_target(target.name)
+ targets = _enrich_targets(targets, scheduler=scheduler)
return TargetListResponse(targets=targets, total=len(targets))
@@ -163,7 +201,7 @@ async def get_target(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Target '{name}' not found",
)
- return target
+ return _enrich_target(target, scheduler=_scheduler_service)
@router.get(
@@ -239,18 +277,30 @@ async def execute_command(
detail=f"Command '{request.command_name}' not found in configuration",
)
- # Execute the command via Labgrid Coordinator
+ # Execute the command via the preset-aware execution service if available.
logger.info(f"Executing command '{command.name}' on target '{name}'")
+ target = _enrich_target(target, scheduler=_scheduler_service)
rollback_target = target.model_dump(mode="json")
+ can_optimistically_acquire = (
+ target.command_capable
+ if target.command_capable is not None
+ else target.status != "offline"
+ )
try:
- optimistic_target = dict(rollback_target)
- optimistic_target["status"] = "acquired"
- optimistic_target["acquired_by"] = LABGRID_DASHBOARD_USER
- await broadcast_target_update(optimistic_target)
-
- # Execute command through the labgrid client
- result_output, exit_code = await client.execute_command(name, command.command)
+ if can_optimistically_acquire:
+ optimistic_target = dict(rollback_target)
+ optimistic_target["status"] = "acquired"
+ optimistic_target["acquired_by"] = LABGRID_DASHBOARD_USER
+ await broadcast_target_update(optimistic_target)
+
+ if _command_execution_service is not None:
+ result_output, exit_code = await _command_execution_service.execute_command(
+ name,
+ command.command,
+ )
+ else:
+ result_output, exit_code = await client.execute_command(name, command.command)
output = CommandOutput(
command=command.command,
@@ -281,7 +331,10 @@ async def execute_command(
try:
updated_target = await client.get_place_info(name)
if updated_target:
- target_update = updated_target.model_dump(mode="json")
+ target_update = _enrich_target(
+ updated_target,
+ scheduler=_scheduler_service,
+ ).model_dump(mode="json")
except Exception as e:
logger.warning(f"Failed to refresh target state for '{name}': {e}")
@@ -376,6 +429,15 @@ async def set_target_preset(
preset_service.set_target_preset(name, request.preset_id)
logger.info(f"Set preset for target '{name}' to '{request.preset_id}'")
+ updated_target = await client.get_place_info(name)
+ if updated_target is not None:
+ await broadcast_target_update(
+ _enrich_target(
+ updated_target,
+ scheduler=_scheduler_service,
+ ).model_dump(mode="json")
+ )
+
# Convert to summary Preset
preset = Preset(
id=preset_detail.id,
diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py
index 8edeb73..f99b73f 100644
--- a/backend/app/api/websocket.py
+++ b/backend/app/api/websocket.py
@@ -5,11 +5,12 @@
import json
import logging
from datetime import datetime, timezone
-from typing import Any, Dict
+from typing import Any, Dict, List
from app.api.connection_manager import manager
from app.models.target import CommandOutput, ScheduledCommandOutput
from app.services.command_service import CommandService
+from app.services.command_execution_service import CommandExecutionService
from app.config import LABGRID_DASHBOARD_USER
from app.services.labgrid_client import (
LabgridClient,
@@ -26,6 +27,8 @@
_labgrid_client: LabgridClient | None = None
_command_service: CommandService | None = None
_scheduler_service: SchedulerService | None = None
+_preset_service: Any | None = None
+_command_execution_service: CommandExecutionService | None = None
def set_labgrid_client(client: LabgridClient) -> None:
@@ -48,6 +51,45 @@ def set_scheduler_service(service: SchedulerService) -> None:
service.set_notify_callback(broadcast_scheduled_output)
+def set_preset_service(service: Any | None) -> None:
+ """Set the global preset service instance."""
+ global _preset_service
+ _preset_service = service
+
+
+def set_command_execution_service(
+ service: CommandExecutionService | None,
+) -> None:
+ """Set the global command execution service instance."""
+ global _command_execution_service
+ _command_execution_service = service
+
+
+def _enrich_target(target):
+ """Add command capability and scheduled output data to a target."""
+ if _command_execution_service is not None:
+ target = _command_execution_service.enrich_target(target)
+
+ if _scheduler_service is not None:
+ target.scheduled_outputs = _scheduler_service.get_outputs_for_target(target.name)
+
+ return target
+
+
+def _serialize_targets(targets: List[Any]) -> List[Dict[str, Any]]:
+ """Serialize and enrich a list of targets for WebSocket transport."""
+ if _command_execution_service is not None:
+ targets = _command_execution_service.enrich_targets(targets)
+
+ if _scheduler_service is not None:
+ for target in targets:
+ target.scheduled_outputs = _scheduler_service.get_outputs_for_target(
+ target.name
+ )
+
+ return [target.model_dump(mode="json") for target in targets]
+
+
async def handle_subscribe(websocket: WebSocket, data: Dict[str, Any]) -> None:
"""Handle subscribe message from client.
@@ -61,17 +103,11 @@ async def handle_subscribe(websocket: WebSocket, data: Dict[str, Any]) -> None:
# Send initial targets list with scheduled outputs
if _labgrid_client:
targets_list = await _labgrid_client.get_places()
- # Enrich with scheduled outputs
- if _scheduler_service:
- for target in targets_list:
- target.scheduled_outputs = _scheduler_service.get_outputs_for_target(
- target.name
- )
await manager.send_to(
websocket,
{
"type": "targets_list",
- "data": [t.model_dump(mode='json') for t in targets_list],
+ "data": _serialize_targets(targets_list),
},
)
logger.info(f"Client subscribed to: {targets}")
@@ -118,9 +154,16 @@ async def handle_execute_command(websocket: WebSocket, data: Dict[str, Any]) ->
},
)
return
+ target = _enrich_target(target)
+
+ preset_id = _preset_service.get_target_preset(target_name) if _preset_service else None
# Get the command from configuration
- command = _command_service.get_command_by_name(command_name)
+ command = None
+ if preset_id and _command_service:
+ command = _command_service.get_command_by_name_for_preset(preset_id, command_name)
+ if command is None and _command_service:
+ command = _command_service.get_command_by_name(command_name)
if command is None:
await manager.send_to(
websocket,
@@ -138,17 +181,28 @@ async def handle_execute_command(websocket: WebSocket, data: Dict[str, Any]) ->
f"Executing command '{command.name}' on target '{target_name}' via WebSocket"
)
rollback_target = target.model_dump(mode="json")
+ can_optimistically_acquire = (
+ target.command_capable
+ if target.command_capable is not None
+ else target.status != "offline"
+ )
try:
- optimistic_target = dict(rollback_target)
- optimistic_target["status"] = "acquired"
- optimistic_target["acquired_by"] = LABGRID_DASHBOARD_USER
- await broadcast_target_update(optimistic_target)
-
- # Execute command through the labgrid client
- result_output, exit_code = await _labgrid_client.execute_command(
- target_name, command.command
- )
+ if can_optimistically_acquire:
+ optimistic_target = dict(rollback_target)
+ optimistic_target["status"] = "acquired"
+ optimistic_target["acquired_by"] = LABGRID_DASHBOARD_USER
+ await broadcast_target_update(optimistic_target)
+
+ if _command_execution_service is not None:
+ result_output, exit_code = await _command_execution_service.execute_command(
+ target_name,
+ command.command,
+ )
+ else:
+ result_output, exit_code = await _labgrid_client.execute_command(
+ target_name, command.command
+ )
output = CommandOutput(
command=command.command,
@@ -180,7 +234,9 @@ async def handle_execute_command(websocket: WebSocket, data: Dict[str, Any]) ->
try:
updated_target = await _labgrid_client.get_place_info(target_name)
if updated_target:
- target_update = updated_target.model_dump(mode="json")
+ target_update = _enrich_target(updated_target).model_dump(
+ mode="json"
+ )
except Exception as e:
logger.warning(
f"Failed to refresh target state for '{target_name}': {e}"
@@ -234,17 +290,11 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
# Send initial targets list on connection with scheduled outputs
if _labgrid_client:
targets_list = await _labgrid_client.get_places()
- # Enrich with scheduled outputs
- if _scheduler_service:
- for target in targets_list:
- target.scheduled_outputs = (
- _scheduler_service.get_outputs_for_target(target.name)
- )
await manager.send_to(
websocket,
{
"type": "targets_list",
- "data": [t.model_dump(mode='json') for t in targets_list],
+ "data": _serialize_targets(targets_list),
},
)
@@ -294,13 +344,18 @@ async def broadcast_target_update(target_data: Dict[str, Any]) -> None:
target_data: The target data to broadcast.
"""
target_name = target_data.get("name", "")
-
- # Enrich with scheduled outputs if available
- if _scheduler_service and target_name:
+ if (
+ _labgrid_client
+ and target_name
+ and "command_capable" not in target_data
+ ):
+ updated_target = await _labgrid_client.get_place_info(target_name)
+ if updated_target is not None:
+ target_data = _enrich_target(updated_target).model_dump(mode="json")
+ elif _scheduler_service and target_name:
scheduled_outputs = _scheduler_service.get_outputs_for_target(target_name)
- # Serialize ScheduledCommandOutput objects to JSON-compatible dicts
target_data["scheduled_outputs"] = {
- cmd_name: output.model_dump(mode='json')
+ cmd_name: output.model_dump(mode="json")
for cmd_name, output in scheduled_outputs.items()
}
@@ -317,16 +372,10 @@ async def broadcast_targets_list() -> None:
"""Broadcast current targets list to all connected clients."""
if _labgrid_client:
targets_list = await _labgrid_client.get_places()
- # Enrich with scheduled outputs
- if _scheduler_service:
- for target in targets_list:
- target.scheduled_outputs = _scheduler_service.get_outputs_for_target(
- target.name
- )
await manager.broadcast(
{
"type": "targets_list",
- "data": [t.model_dump(mode='json') for t in targets_list],
+ "data": _serialize_targets(targets_list),
},
)
diff --git a/backend/app/main.py b/backend/app/main.py
index 65075a9..76fa515 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -13,17 +13,25 @@
from app.api import api_router
from app.api.routes.health import set_labgrid_client as set_health_labgrid_client
from app.api.routes.targets import set_command_service as set_targets_command_service
+from app.api.routes.targets import (
+ set_command_execution_service as set_targets_command_execution_service,
+)
from app.api.routes.targets import set_labgrid_client as set_targets_labgrid_client
from app.api.routes.targets import set_preset_service as set_targets_preset_service
from app.api.routes.targets import (
set_scheduler_service as set_targets_scheduler_service,
)
from app.api.websocket import broadcast_target_update, broadcast_targets_list
+from app.api.websocket import (
+ set_command_execution_service as set_ws_command_execution_service,
+)
from app.api.websocket import set_command_service as set_ws_command_service
from app.api.websocket import set_labgrid_client as set_ws_labgrid_client
+from app.api.websocket import set_preset_service as set_ws_preset_service
from app.api.websocket import set_scheduler_service as set_ws_scheduler_service
from app.config import get_settings
from app.services.command_service import CommandService
+from app.services.command_execution_service import CommandExecutionService
from app.services.labgrid_client import LabgridClient
from app.services.labgrid_client import LabgridConnectionError
from app.services.preset_service import PresetService
@@ -46,6 +54,7 @@
command_service: CommandService | None = None
scheduler_service: SchedulerService | None = None
preset_service: PresetService | None = None
+command_execution_service: CommandExecutionService | None = None
async def wait_for_targets_ready(
@@ -145,7 +154,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
- Initializing preset service
- Starting scheduled command execution
"""
- global labgrid_client, command_service, scheduler_service, preset_service
+ global labgrid_client
+ global command_service
+ global scheduler_service
+ global preset_service
+ global command_execution_service
settings = get_settings()
logger.info("Starting Labgrid Dashboard Backend...")
@@ -187,6 +200,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
)
await labgrid_client.disconnect()
+ command_execution_service = CommandExecutionService(
+ labgrid_client=labgrid_client,
+ command_service=command_service,
+ preset_service=preset_service,
+ )
+
# Initialize scheduler service with preset support
scheduler_service = SchedulerService()
@@ -198,7 +217,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
preset_commands[preset.id] = preset_detail.scheduled_commands
scheduler_service.set_preset_commands(preset_commands)
- scheduler_service.set_execute_callback(labgrid_client.execute_command)
+ scheduler_service.set_execute_callback(command_execution_service.execute_command)
scheduler_service.set_get_targets_callback(labgrid_client.get_schedulable_places)
scheduler_service.set_get_target_preset_callback(preset_service.get_target_preset)
@@ -206,10 +225,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
set_health_labgrid_client(labgrid_client)
set_targets_labgrid_client(labgrid_client)
set_targets_command_service(command_service)
+ set_targets_command_execution_service(command_execution_service)
set_targets_scheduler_service(scheduler_service)
set_targets_preset_service(preset_service)
set_ws_labgrid_client(labgrid_client)
set_ws_command_service(command_service)
+ set_ws_command_execution_service(command_execution_service)
+ set_ws_preset_service(preset_service)
set_ws_scheduler_service(scheduler_service)
async def handle_target_update(target_name: str, target_data: dict) -> None:
diff --git a/backend/app/models/target.py b/backend/app/models/target.py
index 02efe5c..9faecf7 100644
--- a/backend/app/models/target.py
+++ b/backend/app/models/target.py
@@ -3,6 +3,7 @@
"""
from datetime import datetime, timezone
+import os
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
@@ -42,6 +43,110 @@ class ScheduledCommand(BaseModel):
description: str = Field(default="", description="Optional description of what this command does")
+ExecutionTransport = Literal["serial", "ssh"]
+
+
+class SerialCommandExecutionConfig(BaseModel):
+ """Serial console execution settings for a preset."""
+
+ resource_name: Optional[str] = Field(
+ default=None,
+ description="Optional named serial resource to use for command execution",
+ )
+ prompt: str = Field(
+ default=r".*[#\$] ",
+ description="Regex for the ready shell prompt",
+ )
+ login_prompt: str = Field(
+ default=r"(?i)login: ?",
+ description="Regex for the serial login prompt",
+ )
+ username: Optional[str] = Field(
+ default=None,
+ description="Static username for serial login",
+ )
+ username_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the serial login username",
+ )
+ password: Optional[str] = Field(
+ default=None,
+ description="Static password for serial login",
+ )
+ password_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the serial login password",
+ )
+ console_ready: str = Field(
+ default="",
+ description="Optional regex shown before a console becomes interactive",
+ )
+ login_timeout_seconds: int = Field(
+ default=60,
+ ge=1,
+ description="Maximum wait time for serial login",
+ )
+ await_login_timeout_seconds: int = Field(
+ default=2,
+ ge=1,
+ description="Silence window before sending a newline during login detection",
+ )
+ post_login_settle_time_seconds: int = Field(
+ default=0,
+ ge=0,
+ description="Optional settle delay after login before the first prompt check",
+ )
+ command_timeout_seconds: Optional[int] = Field(
+ default=None,
+ ge=1,
+ description="Optional command timeout override for serial execution",
+ )
+
+ def resolve_username(self) -> str:
+ """Resolve the serial username from inline value or environment."""
+ if self.username:
+ return self.username
+ if self.username_env:
+ value = os.environ.get(self.username_env)
+ if value:
+ return value
+ return "root"
+
+ def resolve_password(self) -> Optional[str]:
+ """Resolve the serial password from inline value or environment."""
+ if self.password is not None:
+ return self.password
+ if self.password_env:
+ return os.environ.get(self.password_env)
+ return None
+
+
+class SSHCommandExecutionConfig(BaseModel):
+ """SSH execution settings for a preset."""
+
+ resource_name: Optional[str] = Field(
+ default=None,
+ description="Optional named SSH-capable resource to prefer",
+ )
+
+
+class CommandExecutionConfig(BaseModel):
+ """Preset-level command execution transport settings."""
+
+ transport_order: List[ExecutionTransport] = Field(
+ default_factory=lambda: ["ssh"],
+ description="Preferred transport order for command execution",
+ )
+ serial: SerialCommandExecutionConfig = Field(
+ default_factory=SerialCommandExecutionConfig,
+ description="Serial console execution settings",
+ )
+ ssh: SSHCommandExecutionConfig = Field(
+ default_factory=SSHCommandExecutionConfig,
+ description="SSH execution settings",
+ )
+
+
class Target(BaseModel):
"""Represents a Labgrid target/place with its current state."""
@@ -59,6 +164,18 @@ class Target(BaseModel):
scheduled_outputs: Dict[str, ScheduledCommandOutput] = Field(
default_factory=dict, description="Latest outputs from scheduled commands (keyed by command name)"
)
+ command_capable: Optional[bool] = Field(
+ default=None,
+ description="Whether the target currently supports command execution",
+ )
+ command_capability_error: Optional[str] = Field(
+ default=None,
+ description="Why command execution is currently unavailable for this target",
+ )
+ command_transport: Optional[ExecutionTransport] = Field(
+ default=None,
+ description="The selected execution transport for this target",
+ )
class Command(BaseModel):
@@ -102,6 +219,10 @@ class PresetDetail(BaseModel):
auto_refresh_commands: List[str] = Field(
default_factory=list, description="Command names to auto-refresh"
)
+ command_execution: CommandExecutionConfig = Field(
+ default_factory=CommandExecutionConfig,
+ description="Transport configuration for command execution on this preset",
+ )
class PresetsConfig(BaseModel):
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
index 54e696d..273fa30 100644
--- a/backend/app/services/__init__.py
+++ b/backend/app/services/__init__.py
@@ -3,6 +3,7 @@
"""
from .command_service import CommandService
+from .command_execution_service import CommandExecutionService
from .labgrid_client import LabgridClient
-__all__ = ["CommandService", "LabgridClient"]
+__all__ = ["CommandService", "CommandExecutionService", "LabgridClient"]
diff --git a/backend/app/services/command_execution_service.py b/backend/app/services/command_execution_service.py
new file mode 100644
index 0000000..ffe2af8
--- /dev/null
+++ b/backend/app/services/command_execution_service.py
@@ -0,0 +1,500 @@
+"""
+Preset-aware command execution service.
+"""
+
+import asyncio
+import contextlib
+import logging
+from typing import Optional, Tuple
+
+from app.config import get_settings
+from app.models.target import (
+ CommandExecutionConfig,
+ ExecutionTransport,
+ SerialCommandExecutionConfig,
+ Target,
+)
+from app.services.command_service import CommandService
+from app.services.labgrid_client import (
+ LabgridClient,
+ LabgridConnectionError,
+ TargetAcquiredByOtherError,
+)
+from app.services.preset_service import PresetService
+
+logger = logging.getLogger(__name__)
+
+
+class TransportExecutionError(RuntimeError):
+ """Raised when a configured command transport fails before command execution."""
+
+ pass
+
+
+class CommandExecutionService:
+ """Resolve target command capabilities and execute commands."""
+
+ def __init__(
+ self,
+ labgrid_client: LabgridClient,
+ command_service: CommandService,
+ preset_service: PresetService,
+ ) -> None:
+ self._labgrid_client = labgrid_client
+ self._command_service = command_service
+ self._preset_service = preset_service
+
+ def enrich_targets(self, targets: list[Target]) -> list[Target]:
+ """Annotate a list of targets with command capability metadata."""
+ return [self.enrich_target(target) for target in targets]
+
+ def enrich_target(self, target: Target) -> Target:
+ """Annotate a target with command capability metadata."""
+ capable, transport, error = self.get_target_command_state(
+ target.name,
+ target_status=target.status,
+ )
+ target.command_capable = capable
+ target.command_transport = transport
+ target.command_capability_error = error
+ return target
+
+ def get_target_command_state(
+ self,
+ target_name: str,
+ *,
+ target_status: Optional[str] = None,
+ ) -> Tuple[bool, Optional[ExecutionTransport], Optional[str]]:
+ """Resolve whether a target currently supports command execution."""
+ if target_status == "offline":
+ return (False, None, "Commands unavailable - target is offline")
+
+ session = getattr(self._labgrid_client, "_session", None)
+ if not self._labgrid_client.connected or session is None:
+ return (
+ False,
+ None,
+ "Commands unavailable - coordinator is disconnected",
+ )
+
+ resource_entries = self._labgrid_client.get_place_resource_entries(target_name)
+ if not resource_entries:
+ return (
+ False,
+ None,
+ f"Commands unavailable - target '{target_name}' has no matched resources",
+ )
+
+ preset_id = self._get_target_preset_id(target_name)
+ execution_config = self._command_service.get_execution_config_for_preset(
+ preset_id
+ )
+ transport = self._resolve_available_transport(resource_entries, execution_config)
+ if transport is None:
+ return (
+ False,
+ None,
+ self._build_capability_error(target_name, execution_config),
+ )
+
+ return (True, transport, None)
+
+ async def execute_command(self, target_name: str, command: str) -> Tuple[str, int]:
+ """Execute a command using the preset's configured transport order."""
+ if not self._labgrid_client.connected or not getattr(
+ self._labgrid_client, "_session", None
+ ):
+ logger.warning("Not connected to coordinator")
+ return ("Error: Not connected to coordinator", 1)
+
+ target_lock = self._labgrid_client._command_locks.setdefault(
+ target_name,
+ asyncio.Lock(),
+ )
+
+ try:
+ async with target_lock:
+ acquired_here = await self._labgrid_client.acquire_target(target_name)
+
+ try:
+ return await self._execute_with_transport_order(target_name, command)
+ finally:
+ if acquired_here:
+ released = await self._labgrid_client.release_target_with_retry(
+ target_name
+ )
+ if not released:
+ logger.error(
+ "Command succeeded but release failed for '%s'",
+ target_name,
+ )
+
+ except TargetAcquiredByOtherError:
+ raise
+ except FileNotFoundError as exc:
+ logger.error("labgrid-client not found: %s", exc)
+ return ("Error: labgrid-client CLI not found", 1)
+ except TimeoutError as exc:
+ logger.error("Command timeout on %s: %s", target_name, exc)
+ return (f"Error: {exc}", 1)
+ except RuntimeError as exc:
+ logger.error("Command execution error on %s: %s", target_name, exc)
+ return (f"Error: {exc}", 1)
+ except LabgridConnectionError:
+ logger.exception("Coordinator connection lost during execution")
+ return ("Error: Not connected to coordinator", 1)
+ except Exception as exc:
+ logger.exception("Failed to execute command on %s", target_name)
+ return (f"Error: {exc}", 1)
+
+ async def _execute_with_transport_order(
+ self,
+ target_name: str,
+ command: str,
+ ) -> Tuple[str, int]:
+ session = getattr(self._labgrid_client, "_session", None)
+ if session is None:
+ return ("Error: Not connected to coordinator", 1)
+
+ place = self._get_place(target_name)
+ if place is None:
+ return (f"Error: target '{target_name}' has no coordinator place", 1)
+
+ preset_id = self._get_target_preset_id(target_name)
+ execution_config = self._command_service.get_execution_config_for_preset(
+ preset_id
+ )
+ last_transport_error: Optional[str] = None
+
+ for transport in execution_config.transport_order:
+ if transport == "serial":
+ try:
+ result = await self._try_execute_via_serial(
+ place,
+ target_name,
+ command,
+ execution_config.serial,
+ )
+ except TransportExecutionError as exc:
+ last_transport_error = str(exc)
+ logger.warning(
+ "Serial transport failed on '%s', trying next transport: %s",
+ target_name,
+ exc,
+ )
+ continue
+
+ if result is not None:
+ return result
+ continue
+
+ if transport == "ssh":
+ if not self._has_available_ssh_resource(place):
+ continue
+ return await self._execute_via_ssh(target_name, command)
+
+ logger.warning(
+ "Unknown command execution transport '%s' for preset '%s'",
+ transport,
+ preset_id,
+ )
+
+ if last_transport_error:
+ return (f"Error: {last_transport_error}", 1)
+
+ return (
+ f"Error: {self._build_capability_error(target_name, execution_config)}",
+ 1,
+ )
+
+ async def _execute_via_ssh(self, target_name: str, command: str) -> Tuple[str, int]:
+ output = await self._labgrid_client._execute_via_labgrid_client(
+ target_name,
+ command,
+ )
+ return (output, 0)
+
+ async def _try_execute_via_serial(
+ self,
+ place,
+ target_name: str,
+ command: str,
+ serial_config: SerialCommandExecutionConfig,
+ ) -> Optional[Tuple[str, int]]:
+ session = getattr(self._labgrid_client, "_session", None)
+ if session is None:
+ return None
+
+ try:
+ target = session._get_target(place)
+ serial_resource = self._resolve_serial_resource(target, serial_config)
+ if serial_resource is None:
+ return None
+
+ resource_name, resource_cls = serial_resource
+ serial_driver = self._get_or_create_serial_driver(
+ target,
+ resource_name=resource_name,
+ resource_cls=resource_cls,
+ )
+ if serial_driver is None:
+ return None
+
+ shell_driver = self._get_or_create_shell_driver(
+ target,
+ serial_driver_name=serial_driver.name,
+ serial_config=serial_config,
+ )
+ if shell_driver is None:
+ return None
+
+ return await asyncio.to_thread(
+ self._run_serial_command,
+ target,
+ shell_driver,
+ command,
+ serial_config,
+ )
+ except Exception as exc:
+ raise TransportExecutionError(str(exc)) from exc
+
+ def _run_serial_command(
+ self,
+ target,
+ shell_driver,
+ command: str,
+ serial_config: SerialCommandExecutionConfig,
+ ) -> Tuple[str, int]:
+ """Run a command through the labgrid ShellDriver on a serial console."""
+ try:
+ target.activate(shell_driver)
+ timeout = (
+ serial_config.command_timeout_seconds
+ or get_settings().labgrid_command_timeout
+ )
+ stdout_lines, _, exit_code = shell_driver.run(
+ command,
+ timeout=float(timeout),
+ )
+ output = "\n".join(stdout_lines).strip()
+ return (output, exit_code)
+ finally:
+ with contextlib.suppress(Exception):
+ target.deactivate_all_drivers()
+
+ def _get_target_preset_id(self, target_name: str) -> str:
+ preset_id = self._preset_service.get_target_preset(target_name)
+ if preset_id:
+ return preset_id
+ return self._command_service.get_default_preset_id()
+
+ def _get_place(self, target_name: str):
+ """Get a coordinator place by name from the current session."""
+ session = getattr(self._labgrid_client, "_session", None)
+ if session is None:
+ return None
+
+ try:
+ return session.get_place(target_name)
+ except Exception:
+ return None
+
+ def _resolve_available_transport(
+ self,
+ resource_entries,
+ execution_config: CommandExecutionConfig,
+ ) -> Optional[ExecutionTransport]:
+ for transport in execution_config.transport_order:
+ if transport == "serial":
+ if self._has_cached_serial_resource(
+ resource_entries,
+ execution_config.serial,
+ ):
+ return "serial"
+ elif transport == "ssh":
+ if self._has_cached_ssh_resource(resource_entries):
+ return "ssh"
+ return None
+
+ def _build_capability_error(
+ self,
+ target_name: str,
+ execution_config: CommandExecutionConfig,
+ ) -> str:
+ serial_config = execution_config.serial
+ transport_order = execution_config.transport_order
+
+ if "serial" in transport_order and serial_config.resource_name:
+ serial_reason = (
+ f"serial resource '{serial_config.resource_name}' is not available"
+ )
+ elif "serial" in transport_order:
+ serial_reason = "no serial console is available"
+ else:
+ serial_reason = ""
+
+ if "ssh" in transport_order:
+ ssh_reason = "no SSH service is available"
+ else:
+ ssh_reason = ""
+
+ reasons = [reason for reason in (serial_reason, ssh_reason) if reason]
+ if reasons:
+ return f"Commands unavailable - {' and '.join(reasons)} for '{target_name}'"
+ return f"Commands unavailable - no supported transport is configured for '{target_name}'"
+
+ def _resolve_serial_resource(
+ self,
+ target,
+ serial_config: SerialCommandExecutionConfig,
+ ) -> Optional[Tuple[str, str]]:
+ """Find the configured or first available serial resource on a target."""
+ configured_name = serial_config.resource_name
+ allowed_classes = {"SerialPort", "NetworkSerialPort"}
+ candidates: list[Tuple[str, str]] = []
+
+ for resource in target.resources:
+ resource_cls_name = type(resource).__name__
+ if resource_cls_name not in allowed_classes:
+ continue
+ if not getattr(resource, "avail", True):
+ continue
+
+ candidates.append((resource.name, resource_cls_name))
+ if configured_name and resource.name == configured_name:
+ return (resource.name, resource_cls_name)
+
+ if configured_name:
+ return None
+ if candidates:
+ return candidates[0]
+ return None
+
+ def _has_cached_serial_resource(
+ self,
+ resource_entries,
+ serial_config: SerialCommandExecutionConfig,
+ ) -> bool:
+ """Check the cached place resources for a usable serial console."""
+ configured_name = serial_config.resource_name
+ allowed_classes = {"SerialPort", "NetworkSerialPort"}
+
+ for _, _, res_data in resource_entries:
+ resource_name = res_data.get("name")
+ resource_cls = res_data.get("cls") or res_data.get("resource_type")
+ if resource_cls not in allowed_classes:
+ continue
+ if not res_data.get("avail", True):
+ continue
+ if configured_name and resource_name != configured_name:
+ continue
+ return True
+
+ return False
+
+ def _has_cached_ssh_resource(self, resource_entries) -> bool:
+ """Check the cached place resources for a usable SSH service."""
+ for _, _, res_data in resource_entries:
+ resource_cls = res_data.get("cls") or res_data.get("resource_type")
+ if resource_cls != "NetworkService":
+ continue
+ if res_data.get("avail", True):
+ return True
+ return False
+
+ def _has_available_ssh_resource(self, place) -> bool:
+ """Check whether a place exposes a usable SSH-capable resource."""
+ session = getattr(self._labgrid_client, "_session", None)
+ if session is None:
+ return False
+
+ try:
+ resource_entries = session.get_target_resources(place)
+ except Exception:
+ return False
+
+ for (_, resource_cls), resource_entry in resource_entries.items():
+ resource_cls_name = (
+ resource_cls
+ if isinstance(resource_cls, str)
+ else getattr(resource_cls, "__name__", str(resource_cls))
+ )
+ if resource_cls_name != "NetworkService":
+ continue
+ if getattr(resource_entry, "avail", True):
+ return True
+
+ return False
+
+ def _get_or_create_serial_driver(
+ self,
+ target,
+ *,
+ resource_name: str,
+ resource_cls: str,
+ ):
+ """Get or bind a SerialDriver for a specific resource."""
+ from labgrid.driver import SerialDriver
+
+ try:
+ resource = target.get_resource(
+ resource_cls,
+ name=resource_name,
+ wait_avail=False,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to resolve serial resource '%s' (%s): %s",
+ resource_name,
+ resource_cls,
+ exc,
+ )
+ return None
+
+ try:
+ return target.get_driver(
+ SerialDriver,
+ resource=resource,
+ activate=False,
+ )
+ except Exception:
+ pass
+
+ driver_name = f"serial-{resource_name}"
+ target.set_binding_map({"port": resource_name})
+ return SerialDriver(target, driver_name)
+
+ def _get_or_create_shell_driver(
+ self,
+ target,
+ *,
+ serial_driver_name: str,
+ serial_config: SerialCommandExecutionConfig,
+ ):
+ """Get or bind a ShellDriver on top of a serial console."""
+ from labgrid.driver import ShellDriver
+
+ driver_name = f"shell-{serial_driver_name}"
+ try:
+ return target.get_driver(
+ ShellDriver,
+ name=driver_name,
+ activate=False,
+ )
+ except Exception:
+ pass
+
+ target.set_binding_map({"console": serial_driver_name})
+ shell_driver = ShellDriver(
+ target,
+ driver_name,
+ prompt=serial_config.prompt,
+ login_prompt=serial_config.login_prompt,
+ username=serial_config.resolve_username(),
+ password=serial_config.resolve_password(),
+ login_timeout=serial_config.login_timeout_seconds,
+ console_ready=serial_config.console_ready,
+ await_login_timeout=serial_config.await_login_timeout_seconds,
+ post_login_settle_time=serial_config.post_login_settle_time_seconds,
+ )
+ return shell_driver
diff --git a/backend/app/services/command_service.py b/backend/app/services/command_service.py
index 271d1e0..d625dbd 100644
--- a/backend/app/services/command_service.py
+++ b/backend/app/services/command_service.py
@@ -12,11 +12,14 @@
import yaml
from app.models.target import (
Command,
+ CommandExecutionConfig,
CommandsConfig,
Preset,
PresetDetail,
PresetsConfig,
ScheduledCommand,
+ SerialCommandExecutionConfig,
+ SSHCommandExecutionConfig,
)
logger = logging.getLogger(__name__)
@@ -107,6 +110,9 @@ def _load_presets_format(self, data: dict) -> None:
]
auto_refresh = preset_data.get("auto_refresh_commands", [])
+ command_execution = self._parse_command_execution_config(
+ preset_data.get("command_execution")
+ )
presets[preset_id] = PresetDetail(
id=preset_id,
@@ -115,6 +121,7 @@ def _load_presets_format(self, data: dict) -> None:
commands=commands,
scheduled_commands=scheduled_commands,
auto_refresh_commands=auto_refresh,
+ command_execution=command_execution,
)
self._presets_config = PresetsConfig(
@@ -181,6 +188,7 @@ def _load_legacy_format(self, data: dict) -> None:
commands=commands,
scheduled_commands=scheduled_commands,
auto_refresh_commands=auto_refresh,
+ command_execution=CommandExecutionConfig(),
)
self._presets_config = PresetsConfig(
@@ -274,6 +282,15 @@ def get_auto_refresh_commands_for_preset(self, preset_id: str) -> List[str]:
return preset.auto_refresh_commands
return []
+ def get_execution_config_for_preset(
+ self, preset_id: str
+ ) -> CommandExecutionConfig:
+ """Get the command execution config for a specific preset."""
+ preset = self.get_preset(preset_id)
+ if preset:
+ return preset.command_execution
+ return CommandExecutionConfig()
+
def get_default_preset_id(self) -> str:
"""Get the default preset ID.
@@ -386,3 +403,31 @@ def reload(self) -> None:
self._presets_config = None
self._legacy_config = None
self.load()
+
+ def _parse_command_execution_config(
+ self, execution_data: Optional[dict]
+ ) -> CommandExecutionConfig:
+ """Parse preset-level command execution config with safe defaults."""
+ execution_data = execution_data or {}
+ transport_order = execution_data.get("transport_order")
+
+ if isinstance(transport_order, list) and transport_order:
+ normalized_order = [
+ str(transport).strip().lower()
+ for transport in transport_order
+ if str(transport).strip()
+ ]
+ elif execution_data.get("serial"):
+ normalized_order = ["serial", "ssh"]
+ else:
+ normalized_order = ["ssh"]
+
+ return CommandExecutionConfig(
+ transport_order=normalized_order,
+ serial=SerialCommandExecutionConfig.model_validate(
+ execution_data.get("serial") or {}
+ ),
+ ssh=SSHCommandExecutionConfig.model_validate(
+ execution_data.get("ssh") or {}
+ ),
+ )
diff --git a/backend/app/services/labgrid_client.py b/backend/app/services/labgrid_client.py
index 70a12e5..dbb5c2f 100644
--- a/backend/app/services/labgrid_client.py
+++ b/backend/app/services/labgrid_client.py
@@ -97,6 +97,7 @@ async def connect(self) -> bool:
# Get the current event loop
loop = asyncio.get_event_loop()
+ os.environ["LG_USERNAME"] = LABGRID_DASHBOARD_USER
# Create ClientSession with address and loop
# Using keyword arguments for attrs-generated constructor
@@ -232,7 +233,15 @@ async def _refresh_cache(self) -> None:
if exporter_name not in current_resources:
current_resources[exporter_name] = {}
- current_resources[exporter_name][res_type] = {
+ resource_key = (
+ res_type
+ if group_name == "default"
+ else f"{group_name}/{res_type}"
+ )
+
+ current_resources[exporter_name][resource_key] = {
+ "name": group_name,
+ "resource_type": res_type,
"cls": cls_name,
"params": params,
"acquired": acquired,
@@ -634,6 +643,12 @@ def _get_place_resource_entries(
return entries
+ def get_place_resource_entries(
+ self, place_name: str
+ ) -> List[Tuple[str, str, Dict[str, Any]]]:
+ """Public wrapper for cached place resource entries."""
+ return self._get_place_resource_entries(place_name)
+
def _get_place_exporters(
self, place_name: str, place_data: Dict[str, Any]
) -> List[str]:
diff --git a/backend/commands.yaml b/backend/commands.yaml
index 234bd4e..120ed19 100644
--- a/backend/commands.yaml
+++ b/backend/commands.yaml
@@ -11,6 +11,14 @@ presets:
basic:
name: "Basic"
description: "Standard Linux Commands"
+ command_execution:
+ transport_order:
+ - serial
+ - ssh
+ serial:
+ prompt: ".*[#\\$] "
+ login_prompt: "(?i)login: ?"
+ username: "root"
commands:
- name: "Linux Version"
command: "cat /etc/os-release"
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 3dd8a47..855a750 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -15,6 +15,9 @@
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api.routes.health import set_labgrid_client as set_health_labgrid_client
+from app.api.routes.targets import (
+ set_command_execution_service as set_targets_command_execution_service,
+)
from app.api.routes.targets import set_command_service as set_targets_command_service
from app.api.routes.targets import set_labgrid_client as set_targets_labgrid_client
from app.api.routes.targets import set_preset_service as set_targets_preset_service
@@ -22,7 +25,14 @@
set_scheduler_service as set_targets_scheduler_service,
)
from app.main import app
-from app.models.target import Command, Preset, PresetDetail, Resource, Target
+from app.models.target import (
+ Command,
+ CommandExecutionConfig,
+ Preset,
+ PresetDetail,
+ Resource,
+ Target,
+)
from app.services.command_service import CommandService
from app.services.labgrid_client import LabgridClient
from app.services.preset_service import PresetService
@@ -117,6 +127,7 @@ def mock_command_service(
service.get_presets.return_value = mock_presets
service.get_default_preset_id.return_value = "basic"
service.get_commands_for_preset.return_value = mock_commands
+ service.get_execution_config_for_preset.return_value = CommandExecutionConfig()
service.get_command_by_name_for_preset.side_effect = lambda preset_id, name: next(
(c for c in mock_commands if c.name == name), None
)
@@ -169,6 +180,7 @@ async def client(
set_health_labgrid_client(mock_labgrid_client)
set_targets_labgrid_client(mock_labgrid_client)
set_targets_command_service(mock_command_service)
+ set_targets_command_execution_service(None)
set_targets_preset_service(mock_preset_service)
set_targets_scheduler_service(mock_scheduler_service)
@@ -180,6 +192,7 @@ async def client(
set_health_labgrid_client(None) # type: ignore
set_targets_labgrid_client(None) # type: ignore
set_targets_command_service(None) # type: ignore
+ set_targets_command_execution_service(None)
set_targets_preset_service(None) # type: ignore
set_targets_scheduler_service(None) # type: ignore
diff --git a/backend/tests/test_command_service_presets.py b/backend/tests/test_command_service_presets.py
index 07e9f74..3ce7cf4 100644
--- a/backend/tests/test_command_service_presets.py
+++ b/backend/tests/test_command_service_presets.py
@@ -22,6 +22,13 @@ def presets_yaml_content(self) -> str:
basic:
name: "Basic"
description: "Standard Linux commands"
+ command_execution:
+ transport_order: [serial, ssh]
+ serial:
+ resource_name: "console"
+ prompt: ".*# "
+ login_prompt: "login:"
+ username: "root"
commands:
- name: "Linux Version"
command: "cat /etc/os-release"
@@ -127,6 +134,27 @@ def test_get_preset(self, presets_yaml_content: str):
assert preset.description == "Standard Linux commands"
assert len(preset.commands) == 2
assert len(preset.scheduled_commands) == 1
+ assert preset.command_execution.transport_order == ["serial", "ssh"]
+ assert preset.command_execution.serial.resource_name == "console"
+ finally:
+ os.unlink(f.name)
+
+ def test_missing_command_execution_defaults_to_ssh(
+ self,
+ legacy_yaml_content: str,
+ ):
+ """Test that legacy/basic presets default to SSH-only execution."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
+ f.write(legacy_yaml_content)
+ f.flush()
+
+ try:
+ service = CommandService(commands_file=f.name)
+ service.load()
+
+ execution = service.get_execution_config_for_preset("basic")
+ assert execution.transport_order == ["ssh"]
+ assert execution.serial.resource_name is None
finally:
os.unlink(f.name)
diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py
index 5c0719f..9f826a4 100644
--- a/backend/tests/test_main.py
+++ b/backend/tests/test_main.py
@@ -11,6 +11,11 @@
reconnect_coordinator_in_background,
sync_coordinator_runtime,
)
+from app.models.target import CommandExecutionConfig, SerialCommandExecutionConfig
+from app.services.command_execution_service import (
+ CommandExecutionService,
+ TransportExecutionError,
+)
from app.services.labgrid_client import LabgridConnectionError
@@ -67,3 +72,159 @@ async def fake_sleep(seconds: int) -> None:
assert client.connect.await_count == 2
client.disconnect.assert_awaited_once()
sync_runtime.assert_awaited_once_with(client, 30, 5, callback)
+
+
+@pytest.mark.asyncio
+async def test_command_execution_service_prefers_serial_then_ssh():
+ """Test that serial is preferred over SSH when configured."""
+ labgrid_client = MagicMock()
+ labgrid_client.connected = True
+ labgrid_client._session = MagicMock()
+ labgrid_client._session.get_place.return_value = "place"
+ labgrid_client._command_locks = {}
+ labgrid_client.acquire_target = AsyncMock(return_value=True)
+ labgrid_client.release_target_with_retry = AsyncMock(return_value=True)
+
+ command_service = MagicMock()
+ command_service.get_default_preset_id.return_value = "basic"
+ command_service.get_execution_config_for_preset.return_value = CommandExecutionConfig(
+ transport_order=["serial", "ssh"],
+ serial=SerialCommandExecutionConfig(resource_name="console"),
+ )
+
+ preset_service = MagicMock()
+ preset_service.get_target_preset.return_value = "basic"
+
+ execution_service = CommandExecutionService(
+ labgrid_client=labgrid_client,
+ command_service=command_service,
+ preset_service=preset_service,
+ )
+ execution_service._try_execute_via_serial = AsyncMock(
+ return_value=("serial ok", 0)
+ )
+ execution_service._execute_via_ssh = AsyncMock(return_value=("ssh ok", 0))
+
+ output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+
+ assert output == "serial ok"
+ assert exit_code == 0
+ execution_service._try_execute_via_serial.assert_awaited_once()
+ execution_service._execute_via_ssh.assert_not_awaited()
+ labgrid_client.acquire_target.assert_awaited_once_with("dut-1")
+ labgrid_client.release_target_with_retry.assert_awaited_once_with("dut-1")
+
+
+@pytest.mark.asyncio
+async def test_command_execution_service_falls_back_to_ssh_when_serial_unavailable(
+):
+ """Test that SSH is used when serial transport is unavailable."""
+ labgrid_client = MagicMock()
+ labgrid_client.connected = True
+ labgrid_client._session = MagicMock()
+ labgrid_client._session.get_place.return_value = "place"
+ labgrid_client._command_locks = {}
+ labgrid_client.acquire_target = AsyncMock(return_value=True)
+ labgrid_client.release_target_with_retry = AsyncMock(return_value=True)
+
+ command_service = MagicMock()
+ command_service.get_default_preset_id.return_value = "basic"
+ command_service.get_execution_config_for_preset.return_value = CommandExecutionConfig(
+ transport_order=["serial", "ssh"],
+ serial=SerialCommandExecutionConfig(resource_name="console"),
+ )
+
+ preset_service = MagicMock()
+ preset_service.get_target_preset.return_value = "basic"
+
+ execution_service = CommandExecutionService(
+ labgrid_client=labgrid_client,
+ command_service=command_service,
+ preset_service=preset_service,
+ )
+ execution_service._try_execute_via_serial = AsyncMock(return_value=None)
+ execution_service._execute_via_ssh = AsyncMock(return_value=("ssh ok", 0))
+ execution_service._has_available_ssh_resource = MagicMock(return_value=True)
+
+ output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+
+ assert output == "ssh ok"
+ assert exit_code == 0
+ execution_service._try_execute_via_serial.assert_awaited_once()
+ execution_service._execute_via_ssh.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_command_execution_service_falls_back_to_ssh_when_serial_transport_fails():
+ """Test that SSH is used when serial transport errors before command execution."""
+ labgrid_client = MagicMock()
+ labgrid_client.connected = True
+ labgrid_client._session = MagicMock()
+ labgrid_client._session.get_place.return_value = "place"
+ labgrid_client._command_locks = {}
+ labgrid_client.acquire_target = AsyncMock(return_value=True)
+ labgrid_client.release_target_with_retry = AsyncMock(return_value=True)
+
+ command_service = MagicMock()
+ command_service.get_default_preset_id.return_value = "basic"
+ command_service.get_execution_config_for_preset.return_value = CommandExecutionConfig(
+ transport_order=["serial", "ssh"],
+ serial=SerialCommandExecutionConfig(resource_name="console"),
+ )
+
+ preset_service = MagicMock()
+ preset_service.get_target_preset.return_value = "basic"
+
+ execution_service = CommandExecutionService(
+ labgrid_client=labgrid_client,
+ command_service=command_service,
+ preset_service=preset_service,
+ )
+ execution_service._try_execute_via_serial = AsyncMock(
+ side_effect=TransportExecutionError("serial login failed")
+ )
+ execution_service._execute_via_ssh = AsyncMock(return_value=("ssh ok", 0))
+ execution_service._has_available_ssh_resource = MagicMock(return_value=True)
+
+ output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+
+ assert output == "ssh ok"
+ assert exit_code == 0
+ execution_service._try_execute_via_serial.assert_awaited_once()
+ execution_service._execute_via_ssh.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_command_execution_service_returns_serial_error_when_no_fallback_exists():
+ """Test that the serial transport error is surfaced when no fallback transport exists."""
+ labgrid_client = MagicMock()
+ labgrid_client.connected = True
+ labgrid_client._session = MagicMock()
+ labgrid_client._session.get_place.return_value = "place"
+ labgrid_client._command_locks = {}
+ labgrid_client.acquire_target = AsyncMock(return_value=True)
+ labgrid_client.release_target_with_retry = AsyncMock(return_value=True)
+
+ command_service = MagicMock()
+ command_service.get_default_preset_id.return_value = "basic"
+ command_service.get_execution_config_for_preset.return_value = CommandExecutionConfig(
+ transport_order=["serial"],
+ serial=SerialCommandExecutionConfig(resource_name="console"),
+ )
+
+ preset_service = MagicMock()
+ preset_service.get_target_preset.return_value = "basic"
+
+ execution_service = CommandExecutionService(
+ labgrid_client=labgrid_client,
+ command_service=command_service,
+ preset_service=preset_service,
+ )
+ execution_service._try_execute_via_serial = AsyncMock(
+ side_effect=TransportExecutionError("serial login failed")
+ )
+
+ output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+
+ assert output == "Error: serial login failed"
+ assert exit_code == 1
diff --git a/backend/tests/test_targets.py b/backend/tests/test_targets.py
index e3b0a61..1ccaf9e 100644
--- a/backend/tests/test_targets.py
+++ b/backend/tests/test_targets.py
@@ -2,11 +2,12 @@
Tests for the targets API endpoints.
"""
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
+from app.api.routes.targets import set_command_execution_service
from app.services.labgrid_client import TargetAcquiredByOtherError
@@ -40,6 +41,31 @@ async def test_get_targets_contains_expected_fields(client: AsyncClient):
assert "resources" in target
+@pytest.mark.asyncio
+async def test_get_targets_includes_command_capability_when_service_is_set(
+ client: AsyncClient,
+ mock_targets,
+):
+ """Test that target list responses include backend command capability fields."""
+ execution_service = MagicMock()
+ execution_service.enrich_targets.return_value = mock_targets
+ for target in execution_service.enrich_targets.return_value:
+ target.command_capable = True
+ target.command_transport = "serial"
+ target.command_capability_error = None
+
+ set_command_execution_service(execution_service)
+ try:
+ response = await client.get("/api/targets")
+ finally:
+ set_command_execution_service(None)
+
+ assert response.status_code == 200
+ target = response.json()["targets"][0]
+ assert target["command_capable"] is True
+ assert target["command_transport"] == "serial"
+
+
@pytest.mark.asyncio
async def test_get_target_by_name_found(client: AsyncClient):
"""Test that GET /api/targets/{name} returns a specific target."""
@@ -103,6 +129,33 @@ async def test_execute_command_success(client: AsyncClient):
assert "exit_code" in data
+@pytest.mark.asyncio
+async def test_execute_command_uses_command_execution_service(client: AsyncClient, mock_labgrid_client):
+ """Test that the REST endpoint prefers the configured execution service."""
+ execution_service = MagicMock()
+ execution_service.enrich_target.side_effect = lambda target: target
+ execution_service.enrich_targets.side_effect = lambda targets: targets
+ execution_service.execute_command = AsyncMock(return_value=("serial output", 0))
+ set_command_execution_service(execution_service)
+
+ try:
+ response = await client.post(
+ "/api/targets/test-dut-1/command",
+ json={"command_name": "Test Command"},
+ )
+ finally:
+ set_command_execution_service(None)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["output"] == "serial output"
+ execution_service.execute_command.assert_awaited_once_with(
+ "test-dut-1",
+ "echo test",
+ )
+ mock_labgrid_client.execute_command.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_execute_command_target_not_found(client: AsyncClient):
"""Test that POST /api/targets/{name}/command returns 404 for non-existent target."""
From ee6cc74355f1b100377e30562e99e76dd0053b3e Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Wed, 25 Mar 2026 15:52:12 +0100
Subject: [PATCH 03/20] test(staging): configure raw serial console transport
---
docker/exporter/exporter-config.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/docker/exporter/exporter-config.yaml b/docker/exporter/exporter-config.yaml
index e46a6a2..07b99a8 100644
--- a/docker/exporter/exporter-config.yaml
+++ b/docker/exporter/exporter-config.yaml
@@ -3,6 +3,7 @@ __EXPORTER_NAME__:
host: "__DUT_HOST__"
port: __DUT_PORT__
speed: 115200
+ protocol: "raw"
NetworkService:
address: "__DUT_HOST__"
username: "root"
From 732b45fb8dc73acf296f9a8e50682764895643de Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Wed, 25 Mar 2026 15:52:17 +0100
Subject: [PATCH 04/20] feat(frontend): surface per-target command capability
---
frontend/src/__tests__/TargetTable.test.tsx | 52 ++++++++++++++++++-
.../src/components/TargetTable/TargetRow.tsx | 41 ++++++++++-----
.../components/TargetTable/TargetTable.css | 18 +++++++
frontend/src/types/index.ts | 8 +++
4 files changed, 105 insertions(+), 14 deletions(-)
diff --git a/frontend/src/__tests__/TargetTable.test.tsx b/frontend/src/__tests__/TargetTable.test.tsx
index 1e8500e..71ba5f2 100644
--- a/frontend/src/__tests__/TargetTable.test.tsx
+++ b/frontend/src/__tests__/TargetTable.test.tsx
@@ -3,7 +3,7 @@
*/
import { describe, it, expect, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TargetTable } from "../components/TargetTable";
import type { Target } from "../types";
@@ -45,6 +45,9 @@ const mockTargets: Target[] = [
],
last_command_outputs: [],
scheduled_outputs: {},
+ command_capable: true,
+ command_transport: "serial",
+ command_capability_error: null,
},
{
name: "test-dut-2",
@@ -55,6 +58,9 @@ const mockTargets: Target[] = [
resources: [],
last_command_outputs: [],
scheduled_outputs: {},
+ command_capable: true,
+ command_transport: "ssh",
+ command_capability_error: null,
},
{
name: "test-dut-3",
@@ -65,6 +71,9 @@ const mockTargets: Target[] = [
resources: [],
last_command_outputs: [],
scheduled_outputs: {},
+ command_capable: false,
+ command_transport: null,
+ command_capability_error: "No command transport configured for this device",
},
];
@@ -151,6 +160,47 @@ describe("TargetTable", () => {
expect(rows.length).toBeGreaterThan(1); // Header + data rows
});
+ it("renders the command panel for capable targets", async () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /expand details/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Commands for test-dut-1")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Command transport: serial")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Test Command" })).toBeInTheDocument();
+ });
+
+ it("hides the command panel and shows an error for incapable targets", async () => {
+ const api = await import("../services/api");
+ const getCommandsSpy = vi.mocked(api.api.getCommands);
+ getCommandsSpy.mockClear();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /expand details/i }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Commands unavailable");
+ expect(
+ screen.getByText("No command transport configured for this device"),
+ ).toBeInTheDocument();
+ expect(getCommandsSpy).not.toHaveBeenCalled();
+ });
+
it("shows loading state", () => {
render(
,
diff --git a/frontend/src/components/TargetTable/TargetRow.tsx b/frontend/src/components/TargetTable/TargetRow.tsx
index 8df83f3..e4057ad 100644
--- a/frontend/src/components/TargetTable/TargetRow.tsx
+++ b/frontend/src/components/TargetTable/TargetRow.tsx
@@ -270,7 +270,14 @@ export function TargetRow({
);
};
- const canExecuteCommands = target.status !== "offline";
+ const canExecuteCommands =
+ target.command_capable ?? target.status !== "offline";
+ const commandCapabilityError =
+ target.command_capability_error ??
+ (target.status === "offline"
+ ? "Commands unavailable - target is offline"
+ : "Commands unavailable for this target");
+ const commandTransport = target.command_transport ?? null;
return (
<>
@@ -316,20 +323,28 @@ export function TargetRow({
/* Command Panel Section */
{canExecuteCommands ? (
-
+ <>
+ {commandTransport && (
+
+ Command transport: {commandTransport}
+
+ )}
+
+ >
) : (
-
-
- Commands unavailable - target is offline
+
+
+ Commands unavailable
+
{commandCapabilityError}
)}
diff --git a/frontend/src/components/TargetTable/TargetTable.css b/frontend/src/components/TargetTable/TargetTable.css
index bb19765..3e4fb1d 100644
--- a/frontend/src/components/TargetTable/TargetTable.css
+++ b/frontend/src/components/TargetTable/TargetTable.css
@@ -181,6 +181,24 @@
margin-bottom: 0;
}
+.command-transport-indicator {
+ margin-bottom: 0.75rem;
+ font-size: 0.875rem;
+}
+
+.commands-unavailable {
+ padding: 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-surface);
+}
+
+.commands-unavailable-title {
+ margin: 0 0 0.25rem 0;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
.details-section h4 {
margin: 0;
font-size: 0.875rem;
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 2a9a00c..7f8a814 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -41,6 +41,11 @@ export interface ScheduledCommand {
*/
export type TargetStatus = "available" | "acquired" | "offline";
+/**
+ * Transport used for command execution on a target
+ */
+export type ExecutionTransport = "serial" | "ssh";
+
/**
* Target/DUT representation
*/
@@ -53,6 +58,9 @@ export interface Target {
resources: Resource[];
last_command_outputs: CommandOutput[];
scheduled_outputs: Record
;
+ command_capable?: boolean;
+ command_capability_error?: string | null;
+ command_transport?: ExecutionTransport | null;
}
/**
From 12c9f5afb998708b26607fa943f3b8fe8c744855 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Wed, 25 Mar 2026 15:59:32 +0100
Subject: [PATCH 05/20] docs: update root readme for serial command execution
---
README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++----------
1 file changed, 55 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 2ef5921..61748a7 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,10 @@ Labgrid Dashboard provides a real-time web interface to:
- **Monitor status** - See which devices are available, acquired, or offline
- **Track ownership** - Know who currently has acquired each exporter/target
- **Quick access** - Click on IP addresses to directly access device web interfaces
-- **Execute commands** - Run predefined commands on DUTs and view their outputs
+- **Execute commands** - Run predefined commands on DUTs with serial-first transport and SSH fallback
- **Hardware Presets** - Assign hardware-specific command sets to different targets
- **Grouped Display** - Targets are automatically grouped by their preset type
+- **Transport-aware UI** - Hide command controls on unsupported targets and show a per-device reason
- **Real-time updates** - WebSocket-based live status updates without manual refresh
> 📖 For a quick introduction, see the [Quick Start Guide](quick-start.md).
@@ -163,7 +164,7 @@ docker compose up -d
- Backend API: http://localhost:8000
### Staging Mode
-Runs with simulated DUTs (Alpine Linux containers) and real Labgrid Exporters. Commands are executed via `labgrid-client` CLI, which properly routes through: Backend → Coordinator → Exporter → DUT.
+Runs with simulated DUTs (Alpine Linux containers) and real Labgrid Exporters. Commands prefer a serial shell and fall back to SSH if serial execution is not available for the target/preset.
If the backend starts before the coordinator becomes reachable, it remains available in degraded mode and retries the coordinator connection automatically in the background.
@@ -215,17 +216,53 @@ This demonstrates the "acquired" status in the dashboard with:
**How Command Execution Works:**
1. Frontend sends command request to Backend via HTTP
-2. Backend uses `labgrid-client` CLI to execute commands
-3. `labgrid-client` communicates with Coordinator via gRPC
-4. Coordinator routes to appropriate Exporter
-5. Exporter uses the appropriate driver (ShellDriver, SSHDriver, etc.) to execute on DUT
+2. Backend resolves the target's preset and `command_execution` transport order
+3. Backend prefers serial execution through Labgrid's `SerialDriver` + `ShellDriver`
+4. If serial is unavailable or transport setup fails, Backend falls back to `labgrid-client ssh` when SSH is allowed by the preset
+5. Scheduled commands, REST requests, and WebSocket-triggered commands all use the same backend execution service
6. Output flows back through the same path
-**Supported Connection Types:**
-Labgrid automatically selects the appropriate driver based on available resources:
-- **NetworkSerialPort** - Serial over TCP (used in staging)
-- **USBSerialPort** - Direct USB serial connection
-- **SSHDriver** - SSH connection for network-accessible DUTs
+**Supported Execution Transports:**
+- **Serial shell** - Uses a `NetworkSerialPort` and Labgrid's `ShellDriver`, including login automation and prompt detection
+- **SSH fallback** - Uses `labgrid-client ssh` when a `NetworkService` is available and the preset allows SSH
+- **Unsupported** - If neither transport is available, the backend marks the target as not command-capable and the UI hides command controls for that device
+
+### Command Execution Configuration
+
+Command execution is configured per preset in `backend/commands.yaml`. Each preset can define:
+
+- ordered transport preference
+- serial login automation
+- shell prompt detection
+- serial command timeout overrides
+
+Example:
+
+```yaml
+default_preset: basic
+
+presets:
+ basic:
+ name: "Basic"
+ description: "Standard Linux Commands"
+ command_execution:
+ transport_order:
+ - serial
+ - ssh
+ serial:
+ prompt: ".*[#\\$] "
+ login_prompt: "(?i)login: ?"
+ username: "root"
+ password_env: "LABGRID_SERIAL_PASSWORD"
+ command_timeout_seconds: 60
+```
+
+Notes:
+
+- `transport_order` is evaluated from left to right per target
+- `serial.username` / `serial.password` can be set inline, or via `serial.username_env` / `serial.password_env`
+- the same transport order is used for manual commands and scheduled commands
+- the UI uses backend-provided command capability metadata, so unsupported targets do not show command buttons
## Docker Commands
@@ -481,4 +518,11 @@ Please review the [AGENTS.md](AGENTS.md) and [agent-rules/](agent-rules/) for co
## License
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+ free to submit issues and pull requests.
+
+Please review the [AGENTS.md](AGENTS.md) and [agent-rules/](agent-rules/) for coding guidelines when contributing.
+
+## License
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
From 7af41771d46b2f8e44848081e2fe3282354501a3 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:08:41 +0100
Subject: [PATCH 06/20] chore(release): bump version to 0.1.4
---
backend/app/main.py | 2 +-
frontend/package-lock.json | 4 ++--
frontend/package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 76fa515..071e402 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -289,7 +289,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title="Labgrid Dashboard API",
description="REST API for Labgrid Dashboard - Monitor and interact with DUTs",
- version="0.1.3",
+ version="0.1.4",
lifespan=lifespan,
)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 08a8069..4d6eb47 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "labgrid-dashboard-frontend",
- "version": "0.1.3",
+ "version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "labgrid-dashboard-frontend",
- "version": "0.1.3",
+ "version": "0.1.4",
"dependencies": {
"axios": "^1.7.0",
"react": "^19.2.0",
diff --git a/frontend/package.json b/frontend/package.json
index 99a2e86..4eb7bf7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "labgrid-dashboard-frontend",
"private": true,
- "version": "0.1.3",
+ "version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
From 4d0ddaf572e3ebd40898d47973f07f8acc94f2d2 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:19:48 +0100
Subject: [PATCH 07/20] fix(runtime): prefer image app version at runtime
---
Dockerfile.prod | 1 +
entrypoint.sh | 2 +-
scripts/test-production-image.sh | 14 +++++++++++++-
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/Dockerfile.prod b/Dockerfile.prod
index 0218c11..c64e4ed 100644
--- a/Dockerfile.prod
+++ b/Dockerfile.prod
@@ -71,6 +71,7 @@ ENV PYTHONUNBUFFERED=1
ENV UVICORN_WORKERS=1
ENV UVICORN_LOG_LEVEL=info
ENV APP_VERSION=${APP_VERSION}
+ENV IMAGE_APP_VERSION=${APP_VERSION}
# Copy frontend build artifacts to nginx web root
COPY --from=frontend-builder /app/frontend/dist /var/www/html
diff --git a/entrypoint.sh b/entrypoint.sh
index 6cbc289..6cfe10e 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -5,7 +5,7 @@ set -e
# This file is loaded by the browser before the main application
API_URL_VALUE="${API_URL_EXTERNAL-/api}"
WS_URL_VALUE="${WS_URL_EXTERNAL-/api/ws}"
-APP_VERSION_VALUE="${APP_VERSION-dev}"
+APP_VERSION_VALUE="${IMAGE_APP_VERSION:-${APP_VERSION:-dev}}"
cat > /var/www/html/env-config.js <
Date: Thu, 26 Mar 2026 15:19:53 +0100
Subject: [PATCH 08/20] feat(backend): add exporter ssh bundle execution
support
---
backend/app/api/routes/targets.py | 12 +-
backend/app/api/websocket.py | 12 +-
backend/app/config.py | 5 +
backend/app/main.py | 10 +
backend/app/models/target.py | 65 +++
backend/app/services/__init__.py | 8 +-
.../app/services/command_execution_service.py | 299 +++++++++-
backend/app/services/exporter_ssh_runtime.py | 550 ++++++++++++++++++
backend/app/services/scheduler_service.py | 16 +-
backend/tests/test_exporter_ssh_runtime.py | 224 +++++++
backend/tests/test_main.py | 85 ++-
backend/tests/test_targets.py | 7 +-
12 files changed, 1243 insertions(+), 50 deletions(-)
create mode 100644 backend/app/services/exporter_ssh_runtime.py
create mode 100644 backend/tests/test_exporter_ssh_runtime.py
diff --git a/backend/app/api/routes/targets.py b/backend/app/api/routes/targets.py
index ed341dd..495cec8 100644
--- a/backend/app/api/routes/targets.py
+++ b/backend/app/api/routes/targets.py
@@ -294,19 +294,24 @@ async def execute_command(
optimistic_target["acquired_by"] = LABGRID_DASHBOARD_USER
await broadcast_target_update(optimistic_target)
+ execution_transport = None
if _command_execution_service is not None:
- result_output, exit_code = await _command_execution_service.execute_command(
+ result = await _command_execution_service.execute_command(
name,
command.command,
)
+ result_output, exit_code = result
+ execution_transport = getattr(result, "execution_transport", None)
else:
result_output, exit_code = await client.execute_command(name, command.command)
+ execution_transport = "ssh"
output = CommandOutput(
command=command.command,
output=result_output,
timestamp=datetime.now(timezone.utc),
exit_code=exit_code,
+ execution_transport=execution_transport,
)
except TargetAcquiredByOtherError as e:
logger.warning(f"Command execution blocked by existing owner: {e}")
@@ -317,6 +322,7 @@ async def execute_command(
output=f"Error executing command: {str(e)}",
timestamp=datetime.now(timezone.utc),
exit_code=1,
+ execution_transport=None,
)
except Exception as e:
logger.error(f"Command execution failed: {e}")
@@ -325,8 +331,12 @@ async def execute_command(
output=f"Error executing command: {str(e)}",
timestamp=datetime.now(timezone.utc),
exit_code=1,
+ execution_transport=None,
)
finally:
+ if _command_execution_service is not None:
+ _command_execution_service.record_output(name, output)
+
target_update = rollback_target
try:
updated_target = await client.get_place_info(name)
diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py
index f99b73f..159609b 100644
--- a/backend/app/api/websocket.py
+++ b/backend/app/api/websocket.py
@@ -194,21 +194,26 @@ async def handle_execute_command(websocket: WebSocket, data: Dict[str, Any]) ->
optimistic_target["acquired_by"] = LABGRID_DASHBOARD_USER
await broadcast_target_update(optimistic_target)
+ execution_transport = None
if _command_execution_service is not None:
- result_output, exit_code = await _command_execution_service.execute_command(
+ result = await _command_execution_service.execute_command(
target_name,
command.command,
)
+ result_output, exit_code = result
+ execution_transport = getattr(result, "execution_transport", None)
else:
result_output, exit_code = await _labgrid_client.execute_command(
target_name, command.command
)
+ execution_transport = "ssh"
output = CommandOutput(
command=command.command,
output=result_output,
timestamp=datetime.now(timezone.utc),
exit_code=exit_code,
+ execution_transport=execution_transport,
)
except TargetAcquiredByOtherError as e:
logger.warning(f"Command execution blocked by existing owner: {e}")
@@ -219,6 +224,7 @@ async def handle_execute_command(websocket: WebSocket, data: Dict[str, Any]) ->
output=f"Error executing command: {str(e)}",
timestamp=datetime.now(timezone.utc),
exit_code=1,
+ execution_transport=None,
)
except Exception as e:
logger.error(f"Command execution failed: {e}")
@@ -227,8 +233,12 @@ async def handle_execute_command(websocket: WebSocket, data: Dict[str, Any]) ->
output=f"Error executing command: {str(e)}",
timestamp=datetime.now(timezone.utc),
exit_code=1,
+ execution_transport=None,
)
finally:
+ if _command_execution_service is not None:
+ _command_execution_service.record_output(target_name, output)
+
if _labgrid_client:
target_update = rollback_target
try:
diff --git a/backend/app/config.py b/backend/app/config.py
index 17e6428..1c74701 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
+ extra="ignore",
)
# Labgrid Coordinator settings
@@ -50,6 +51,10 @@ def parse_cors_origins(cls, v: Union[str, List[str], None]) -> List[str]:
# Presets configuration (target-to-preset assignments)
presets_file: str = "target_presets.json"
+ # Exporter SSH bundle configuration
+ exporter_ssh_bundles_dir: str = "/app/exporter-ssh"
+ exporter_ssh_managed_dir: str = ""
+
# Application settings
app_name: str = "Labgrid Dashboard API"
debug: bool = False
diff --git a/backend/app/main.py b/backend/app/main.py
index 071e402..7803572 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -32,6 +32,7 @@
from app.config import get_settings
from app.services.command_service import CommandService
from app.services.command_execution_service import CommandExecutionService
+from app.services.exporter_ssh_runtime import ExporterSSHRuntimeService
from app.services.labgrid_client import LabgridClient
from app.services.labgrid_client import LabgridConnectionError
from app.services.preset_service import PresetService
@@ -164,6 +165,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Starting Labgrid Dashboard Backend...")
reconnect_task: asyncio.Task[None] | None = None
+ exporter_ssh_runtime = ExporterSSHRuntimeService(
+ bundles_dir=settings.exporter_ssh_bundles_dir,
+ managed_dir=settings.exporter_ssh_managed_dir or None,
+ )
+ try:
+ exporter_ssh_runtime.setup()
+ except Exception:
+ logger.exception("Failed to prepare exporter SSH runtime assets")
+
# Initialize command service
command_service = CommandService(commands_file=settings.commands_file)
command_service.load()
diff --git a/backend/app/models/target.py b/backend/app/models/target.py
index 9faecf7..aa8a516 100644
--- a/backend/app/models/target.py
+++ b/backend/app/models/target.py
@@ -23,6 +23,10 @@ class CommandOutput(BaseModel):
output: str = Field(..., description="The command output (stdout/stderr)")
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="When the command was executed")
exit_code: int = Field(..., description="Command exit code (0 = success)")
+ execution_transport: Optional["ExecutionTransport"] = Field(
+ default=None,
+ description="The transport that was actually used for the execution",
+ )
class ScheduledCommandOutput(BaseModel):
@@ -32,6 +36,10 @@ class ScheduledCommandOutput(BaseModel):
output: str = Field(..., description="The command output (stdout/stderr)")
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="When the command was last executed")
exit_code: int = Field(default=0, description="Command exit code (0 = success)")
+ execution_transport: Optional["ExecutionTransport"] = Field(
+ default=None,
+ description="The transport that was actually used for the execution",
+ )
class ScheduledCommand(BaseModel):
@@ -128,6 +136,63 @@ class SSHCommandExecutionConfig(BaseModel):
default=None,
description="Optional named SSH-capable resource to prefer",
)
+ username: Optional[str] = Field(
+ default=None,
+ description="Static username override for SSH execution",
+ )
+ username_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the SSH username",
+ )
+ password: Optional[str] = Field(
+ default=None,
+ description="Static password override for SSH execution",
+ )
+ password_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the SSH password",
+ )
+ keyfile: Optional[str] = Field(
+ default=None,
+ description="Path to a private key file used for SSH execution",
+ )
+ keyfile_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the private key path",
+ )
+ command_timeout_seconds: Optional[int] = Field(
+ default=None,
+ ge=1,
+ description="Optional command timeout override for SSH execution",
+ )
+
+ def resolve_username(self) -> str:
+ """Resolve the SSH username from inline value or environment."""
+ if self.username:
+ return self.username
+ if self.username_env:
+ value = os.environ.get(self.username_env)
+ if value:
+ return value
+ return ""
+
+ def resolve_password(self) -> Optional[str]:
+ """Resolve the SSH password from inline value or environment."""
+ if self.password is not None:
+ return self.password
+ if self.password_env:
+ return os.environ.get(self.password_env)
+ return None
+
+ def resolve_keyfile(self) -> str:
+ """Resolve the SSH private key path from inline value or environment."""
+ if self.keyfile:
+ return self.keyfile
+ if self.keyfile_env:
+ value = os.environ.get(self.keyfile_env)
+ if value:
+ return value
+ return ""
class CommandExecutionConfig(BaseModel):
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
index 273fa30..1ec9c13 100644
--- a/backend/app/services/__init__.py
+++ b/backend/app/services/__init__.py
@@ -4,6 +4,12 @@
from .command_service import CommandService
from .command_execution_service import CommandExecutionService
+from .exporter_ssh_runtime import ExporterSSHRuntimeService
from .labgrid_client import LabgridClient
-__all__ = ["CommandService", "CommandExecutionService", "LabgridClient"]
+__all__ = [
+ "CommandService",
+ "CommandExecutionService",
+ "ExporterSSHRuntimeService",
+ "LabgridClient",
+]
diff --git a/backend/app/services/command_execution_service.py b/backend/app/services/command_execution_service.py
index ffe2af8..ae965e3 100644
--- a/backend/app/services/command_execution_service.py
+++ b/backend/app/services/command_execution_service.py
@@ -4,6 +4,7 @@
import asyncio
import contextlib
+from dataclasses import dataclass
import logging
from typing import Optional, Tuple
@@ -25,6 +26,19 @@
logger = logging.getLogger(__name__)
+@dataclass
+class CommandExecutionResult:
+ """Command execution result with the transport actually used."""
+
+ output: str
+ exit_code: int
+ execution_transport: Optional[ExecutionTransport] = None
+
+ def __iter__(self):
+ yield self.output
+ yield self.exit_code
+
+
class TransportExecutionError(RuntimeError):
"""Raised when a configured command transport fails before command execution."""
@@ -43,6 +57,7 @@ def __init__(
self._labgrid_client = labgrid_client
self._command_service = command_service
self._preset_service = preset_service
+ self._last_command_outputs: dict[str, list] = {}
def enrich_targets(self, targets: list[Target]) -> list[Target]:
"""Annotate a list of targets with command capability metadata."""
@@ -57,8 +72,19 @@ def enrich_target(self, target: Target) -> Target:
target.command_capable = capable
target.command_transport = transport
target.command_capability_error = error
+ target.last_command_outputs = self.get_outputs_for_target(target.name)
return target
+ def record_output(self, target_name: str, output, *, limit: int = 10) -> None:
+ """Store the latest command outputs for a target in memory."""
+ outputs = [output]
+ outputs.extend(self._last_command_outputs.get(target_name, []))
+ self._last_command_outputs[target_name] = outputs[:limit]
+
+ def get_outputs_for_target(self, target_name: str) -> list:
+ """Return the cached manual command outputs for a target."""
+ return list(self._last_command_outputs.get(target_name, []))
+
def get_target_command_state(
self,
target_name: str,
@@ -99,13 +125,20 @@ def get_target_command_state(
return (True, transport, None)
- async def execute_command(self, target_name: str, command: str) -> Tuple[str, int]:
+ async def execute_command(
+ self,
+ target_name: str,
+ command: str,
+ ) -> CommandExecutionResult:
"""Execute a command using the preset's configured transport order."""
if not self._labgrid_client.connected or not getattr(
self._labgrid_client, "_session", None
):
logger.warning("Not connected to coordinator")
- return ("Error: Not connected to coordinator", 1)
+ return CommandExecutionResult(
+ "Error: Not connected to coordinator",
+ 1,
+ )
target_lock = self._labgrid_client._command_locks.setdefault(
target_name,
@@ -133,38 +166,42 @@ async def execute_command(self, target_name: str, command: str) -> Tuple[str, in
raise
except FileNotFoundError as exc:
logger.error("labgrid-client not found: %s", exc)
- return ("Error: labgrid-client CLI not found", 1)
+ return CommandExecutionResult("Error: labgrid-client CLI not found", 1)
except TimeoutError as exc:
logger.error("Command timeout on %s: %s", target_name, exc)
- return (f"Error: {exc}", 1)
+ return CommandExecutionResult(f"Error: {exc}", 1)
except RuntimeError as exc:
logger.error("Command execution error on %s: %s", target_name, exc)
- return (f"Error: {exc}", 1)
+ return CommandExecutionResult(f"Error: {exc}", 1)
except LabgridConnectionError:
logger.exception("Coordinator connection lost during execution")
- return ("Error: Not connected to coordinator", 1)
+ return CommandExecutionResult("Error: Not connected to coordinator", 1)
except Exception as exc:
logger.exception("Failed to execute command on %s", target_name)
- return (f"Error: {exc}", 1)
+ return CommandExecutionResult(f"Error: {exc}", 1)
async def _execute_with_transport_order(
self,
target_name: str,
command: str,
- ) -> Tuple[str, int]:
+ ) -> CommandExecutionResult:
session = getattr(self._labgrid_client, "_session", None)
if session is None:
- return ("Error: Not connected to coordinator", 1)
+ return CommandExecutionResult("Error: Not connected to coordinator", 1)
place = self._get_place(target_name)
if place is None:
- return (f"Error: target '{target_name}' has no coordinator place", 1)
+ return CommandExecutionResult(
+ f"Error: target '{target_name}' has no coordinator place",
+ 1,
+ )
preset_id = self._get_target_preset_id(target_name)
execution_config = self._command_service.get_execution_config_for_preset(
preset_id
)
last_transport_error: Optional[str] = None
+ self._reset_target_proxy_connections(target_name)
for transport in execution_config.transport_order:
if transport == "serial":
@@ -176,11 +213,15 @@ async def _execute_with_transport_order(
execution_config.serial,
)
except TransportExecutionError as exc:
- last_transport_error = str(exc)
+ last_transport_error = self._normalize_transport_error(
+ exc,
+ fallback_message="serial transport failed",
+ )
+ self._reset_target_proxy_connections(target_name)
logger.warning(
"Serial transport failed on '%s', trying next transport: %s",
target_name,
- exc,
+ last_transport_error,
)
continue
@@ -189,9 +230,28 @@ async def _execute_with_transport_order(
continue
if transport == "ssh":
- if not self._has_available_ssh_resource(place):
+ try:
+ result = await self._try_execute_via_ssh(
+ place,
+ command,
+ execution_config.ssh,
+ )
+ except TransportExecutionError as exc:
+ last_transport_error = self._normalize_transport_error(
+ exc,
+ fallback_message="ssh transport failed",
+ )
+ self._reset_target_proxy_connections(target_name)
+ logger.warning(
+ "SSH transport failed on '%s': %s",
+ target_name,
+ last_transport_error,
+ )
continue
- return await self._execute_via_ssh(target_name, command)
+
+ if result is not None:
+ return result
+ continue
logger.warning(
"Unknown command execution transport '%s' for preset '%s'",
@@ -200,19 +260,46 @@ async def _execute_with_transport_order(
)
if last_transport_error:
- return (f"Error: {last_transport_error}", 1)
+ return CommandExecutionResult(f"Error: {last_transport_error}", 1)
- return (
+ return CommandExecutionResult(
f"Error: {self._build_capability_error(target_name, execution_config)}",
1,
)
- async def _execute_via_ssh(self, target_name: str, command: str) -> Tuple[str, int]:
- output = await self._labgrid_client._execute_via_labgrid_client(
- target_name,
- command,
- )
- return (output, 0)
+ def _normalize_transport_error(
+ self,
+ error: Exception,
+ *,
+ fallback_message: str,
+ ) -> str:
+ message = str(error).strip()
+ return message or fallback_message
+
+ def _reset_target_proxy_connections(self, target_name: str) -> None:
+ """Drop cached exporter SSH proxy connections for a target."""
+ try:
+ from labgrid.util.ssh import sshmanager
+ except Exception:
+ return
+
+ aliases: set[str] = set()
+ for _, _, res_data in self._labgrid_client.get_place_resource_entries(target_name):
+ params = res_data.get("params") or {}
+ extra = params.get("extra") or {}
+ proxy = extra.get("proxy")
+ if proxy:
+ aliases.add(proxy)
+
+ connections = getattr(sshmanager, "_connections", {})
+ for alias in aliases:
+ connection = connections.get(alias)
+ if connection is None:
+ continue
+ with contextlib.suppress(Exception):
+ connection.disconnect()
+ with contextlib.suppress(Exception):
+ sshmanager.remove_by_name(alias)
async def _try_execute_via_serial(
self,
@@ -220,7 +307,7 @@ async def _try_execute_via_serial(
target_name: str,
command: str,
serial_config: SerialCommandExecutionConfig,
- ) -> Optional[Tuple[str, int]]:
+ ) -> Optional[CommandExecutionResult]:
session = getattr(self._labgrid_client, "_session", None)
if session is None:
return None
@@ -264,7 +351,7 @@ def _run_serial_command(
shell_driver,
command: str,
serial_config: SerialCommandExecutionConfig,
- ) -> Tuple[str, int]:
+ ) -> CommandExecutionResult:
"""Run a command through the labgrid ShellDriver on a serial console."""
try:
target.activate(shell_driver)
@@ -277,7 +364,7 @@ def _run_serial_command(
timeout=float(timeout),
)
output = "\n".join(stdout_lines).strip()
- return (output, exit_code)
+ return CommandExecutionResult(output, exit_code, "serial")
finally:
with contextlib.suppress(Exception):
target.deactivate_all_drivers()
@@ -312,7 +399,10 @@ def _resolve_available_transport(
):
return "serial"
elif transport == "ssh":
- if self._has_cached_ssh_resource(resource_entries):
+ if self._has_cached_ssh_resource(
+ resource_entries,
+ execution_config.ssh,
+ ):
return "ssh"
return None
@@ -322,6 +412,7 @@ def _build_capability_error(
execution_config: CommandExecutionConfig,
) -> str:
serial_config = execution_config.serial
+ ssh_config = execution_config.ssh
transport_order = execution_config.transport_order
if "serial" in transport_order and serial_config.resource_name:
@@ -333,7 +424,9 @@ def _build_capability_error(
else:
serial_reason = ""
- if "ssh" in transport_order:
+ if "ssh" in transport_order and ssh_config.resource_name:
+ ssh_reason = f"SSH resource '{ssh_config.resource_name}' is not available"
+ elif "ssh" in transport_order:
ssh_reason = "no SSH service is available"
else:
ssh_reason = ""
@@ -392,17 +485,26 @@ def _has_cached_serial_resource(
return False
- def _has_cached_ssh_resource(self, resource_entries) -> bool:
+ def _has_cached_ssh_resource(
+ self,
+ resource_entries,
+ ssh_config,
+ ) -> bool:
"""Check the cached place resources for a usable SSH service."""
+ configured_name = ssh_config.resource_name
+
for _, _, res_data in resource_entries:
+ resource_name = res_data.get("name")
resource_cls = res_data.get("cls") or res_data.get("resource_type")
if resource_cls != "NetworkService":
continue
+ if configured_name and resource_name != configured_name:
+ continue
if res_data.get("avail", True):
return True
return False
- def _has_available_ssh_resource(self, place) -> bool:
+ def _has_available_ssh_resource(self, place, ssh_config) -> bool:
"""Check whether a place exposes a usable SSH-capable resource."""
session = getattr(self._labgrid_client, "_session", None)
if session is None:
@@ -413,7 +515,9 @@ def _has_available_ssh_resource(self, place) -> bool:
except Exception:
return False
- for (_, resource_cls), resource_entry in resource_entries.items():
+ configured_name = ssh_config.resource_name
+
+ for (resource_name, resource_cls), resource_entry in resource_entries.items():
resource_cls_name = (
resource_cls
if isinstance(resource_cls, str)
@@ -421,11 +525,71 @@ def _has_available_ssh_resource(self, place) -> bool:
)
if resource_cls_name != "NetworkService":
continue
+ if configured_name and resource_name != configured_name:
+ continue
if getattr(resource_entry, "avail", True):
return True
return False
+ async def _try_execute_via_ssh(
+ self,
+ place,
+ command: str,
+ ssh_config,
+ ) -> Optional[CommandExecutionResult]:
+ session = getattr(self._labgrid_client, "_session", None)
+ if session is None:
+ return None
+
+ try:
+ target = session._get_target(place)
+ ssh_resource_name = self._resolve_ssh_resource_name(target, ssh_config)
+ if ssh_resource_name is None:
+ return None
+
+ ssh_driver = self._get_or_create_ssh_driver(
+ target,
+ resource_name=ssh_resource_name,
+ ssh_config=ssh_config,
+ )
+ if ssh_driver is None:
+ return None
+
+ return await asyncio.to_thread(
+ self._run_ssh_command,
+ target,
+ ssh_driver,
+ command,
+ ssh_config,
+ )
+ except Exception as exc:
+ raise TransportExecutionError(str(exc)) from exc
+
+ def _resolve_ssh_resource_name(
+ self,
+ target,
+ ssh_config,
+ ) -> Optional[str]:
+ configured_name = ssh_config.resource_name
+ candidates: list[str] = []
+
+ for resource in target.resources:
+ if type(resource).__name__ != "NetworkService":
+ continue
+ if not getattr(resource, "avail", True):
+ continue
+
+ candidates.append(resource.name)
+ if configured_name and resource.name == configured_name:
+ return resource.name
+
+ if configured_name:
+ return None
+ if candidates:
+ return candidates[0]
+ return None
+
def _get_or_create_serial_driver(
self,
target,
@@ -498,3 +662,78 @@ def _get_or_create_shell_driver(
post_login_settle_time=serial_config.post_login_settle_time_seconds,
)
return shell_driver
+
+ def _get_or_create_ssh_driver(
+ self,
+ target,
+ *,
+ resource_name: str,
+ ssh_config,
+ ):
+ """Get or bind an SSHDriver for a specific resource."""
+ from labgrid.driver import SSHDriver
+
+ try:
+ resource = target.get_resource(
+ "NetworkService",
+ name=resource_name,
+ wait_avail=False,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to resolve SSH resource '%s': %s",
+ resource_name,
+ exc,
+ )
+ return None
+
+ driver_name = f"ssh-{resource_name}"
+ try:
+ return target.get_driver(
+ SSHDriver,
+ name=driver_name,
+ activate=False,
+ )
+ except Exception:
+ pass
+
+ target.set_binding_map({"networkservice": resource_name})
+
+ kwargs = {}
+ keyfile = ssh_config.resolve_keyfile()
+ username = ssh_config.resolve_username()
+ password = ssh_config.resolve_password()
+ if keyfile:
+ kwargs["keyfile"] = keyfile
+ if username:
+ kwargs["username"] = username
+ if password is not None:
+ kwargs["password"] = password
+
+ return SSHDriver(target, driver_name, **kwargs)
+
+ def _run_ssh_command(
+ self,
+ target,
+ ssh_driver,
+ command: str,
+ ssh_config,
+ ) -> CommandExecutionResult:
+ """Run a command through the labgrid SSHDriver."""
+ try:
+ target.activate(ssh_driver)
+ timeout = (
+ ssh_config.command_timeout_seconds
+ or get_settings().labgrid_command_timeout
+ )
+ stdout_lines, stderr_lines, exit_code = ssh_driver.run(
+ command,
+ timeout=float(timeout),
+ )
+ output_lines = list(stdout_lines)
+ output_lines.extend(line for line in stderr_lines if line)
+ output = "\n".join(output_lines).strip()
+ return CommandExecutionResult(output, exit_code, "ssh")
+ finally:
+ with contextlib.suppress(Exception):
+ target.deactivate_all_drivers()
diff --git a/backend/app/services/exporter_ssh_runtime.py b/backend/app/services/exporter_ssh_runtime.py
new file mode 100644
index 0000000..0cc1da5
--- /dev/null
+++ b/backend/app/services/exporter_ssh_runtime.py
@@ -0,0 +1,550 @@
+"""
+Runtime preparation for exporter-side SSH access.
+
+This service reads per-exporter SSH bundles from a configured directory and
+materializes the SSH assets needed by labgrid's proxy SSH path:
+
+- a managed SSH config snippet included from ~/.ssh/config
+- a managed known_hosts file
+- copied private keys with strict permissions
+- a wrapper ssh binary for password-authenticated exporters
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Literal, Optional
+
+import yaml
+from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
+
+logger = logging.getLogger(__name__)
+
+MANAGED_CONFIG_INCLUDE = "Include ~/.ssh/labgrid-dashboard/config"
+MANAGED_DIR_NAME = "labgrid-dashboard"
+DEFAULT_PRIVATE_KEY_CANDIDATES = ("id_ed25519", "id_rsa")
+AUTH_FILE_ENV = "LABGRID_DASHBOARD_EXPORTER_SSH_AUTH_FILE"
+REAL_SSH_ENV = "LABGRID_DASHBOARD_REAL_SSH"
+DEFAULT_SSH_WRAPPER_INSTALL_PATH = "/usr/local/bin/ssh"
+
+
+class ExporterSSHAuthConfig(BaseModel):
+ """SSH authentication settings for a single exporter bundle."""
+
+ method: Optional[Literal["private_key", "password"]] = Field(
+ default=None,
+ description="Authentication method for exporter SSH access.",
+ )
+ private_key_path: Optional[str] = Field(
+ default=None,
+ description="Bundle-relative private key path for key-based auth.",
+ )
+ password: Optional[str] = Field(
+ default=None,
+ description="Inline password for password-based auth.",
+ )
+ password_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the SSH password.",
+ )
+
+ @model_validator(mode="after")
+ def _normalize_method(self) -> "ExporterSSHAuthConfig":
+ if self.method is None:
+ if self.private_key_path:
+ self.method = "private_key"
+ elif self.password is not None or self.password_env:
+ self.method = "password"
+ else:
+ self.method = "private_key"
+
+ if self.method == "password" and self.password is None and not self.password_env:
+ raise ValueError(
+ "Password auth requires either 'password' or 'password_env'"
+ )
+
+ return self
+
+
+class ExporterSSHConnectionConfig(BaseModel):
+ """SSH connection settings for a single exporter bundle."""
+
+ user: Optional[str] = Field(
+ default=None,
+ description="Static SSH username for exporter access.",
+ )
+ user_env: Optional[str] = Field(
+ default=None,
+ description="Environment variable containing the SSH username.",
+ )
+ auth: ExporterSSHAuthConfig = Field(default_factory=ExporterSSHAuthConfig)
+
+ def resolve_user(self) -> str:
+ """Resolve the SSH username from inline value or environment."""
+ if self.user:
+ return self.user
+
+ if self.user_env:
+ value = os.environ.get(self.user_env)
+ if value:
+ return value
+
+ return "root"
+
+
+class ExporterSSHBundleConfig(BaseModel):
+ """Declarative SSH access bundle for a single exporter."""
+
+ alias: str = Field(..., description="Exporter alias used by labgrid proxying.")
+ host: str = Field(..., description="DNS name or IP address of the exporter host.")
+ port: int = Field(default=22, ge=1, le=65535)
+ ssh: ExporterSSHConnectionConfig = Field(
+ default_factory=ExporterSSHConnectionConfig
+ )
+ known_hosts: List[str] = Field(
+ default_factory=list,
+ description="One or more complete known_hosts lines for this exporter.",
+ )
+
+ @field_validator("known_hosts", mode="before")
+ @classmethod
+ def _normalize_known_hosts(cls, value) -> List[str]:
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [value.strip()] if value.strip() else []
+ return [str(entry).strip() for entry in value if str(entry).strip()]
+
+ @model_validator(mode="after")
+ def _validate_known_hosts(self) -> "ExporterSSHBundleConfig":
+ if not self.known_hosts:
+ raise ValueError("At least one known_hosts entry is required")
+ return self
+
+
+@dataclass
+class PreparedExporterSSHBundle:
+ """Resolved exporter SSH bundle ready for runtime materialization."""
+
+ alias: str
+ host: str
+ port: int
+ user: str
+ auth_method: Literal["private_key", "password"]
+ known_hosts: List[str]
+ private_key_source: Optional[Path] = None
+ password: Optional[str] = None
+ password_env: Optional[str] = None
+
+
+class ExporterSSHRuntimeService:
+ """Generate managed SSH runtime assets for exporter proxy access."""
+
+ def __init__(
+ self,
+ bundles_dir: str,
+ managed_dir: Optional[str] = None,
+ wrapper_install_path: Optional[str] = DEFAULT_SSH_WRAPPER_INSTALL_PATH,
+ ) -> None:
+ self._bundles_dir = Path(bundles_dir)
+ self._managed_dir_override = Path(managed_dir).expanduser() if managed_dir else None
+ self._wrapper_install_path = (
+ Path(wrapper_install_path).expanduser() if wrapper_install_path else None
+ )
+ self._prepared_bundles: Dict[str, PreparedExporterSSHBundle] = {}
+
+ @property
+ def prepared_bundles(self) -> Dict[str, PreparedExporterSSHBundle]:
+ """Return the resolved exporter bundles keyed by alias."""
+ return dict(self._prepared_bundles)
+
+ def setup(self) -> None:
+ """Load bundles and generate SSH runtime assets if configured."""
+ if not self._bundles_dir.exists():
+ logger.info(
+ "Exporter SSH bundle directory '%s' does not exist; skipping setup",
+ self._bundles_dir,
+ )
+ return
+
+ if not self._bundles_dir.is_dir():
+ logger.warning(
+ "Exporter SSH bundle path '%s' is not a directory; skipping setup",
+ self._bundles_dir,
+ )
+ return
+
+ bundle_dirs = sorted(path for path in self._bundles_dir.iterdir() if path.is_dir())
+ if not bundle_dirs:
+ logger.info(
+ "No exporter SSH bundles found in '%s'; skipping setup",
+ self._bundles_dir,
+ )
+ return
+
+ prepared: Dict[str, PreparedExporterSSHBundle] = {}
+ for bundle_dir in bundle_dirs:
+ try:
+ bundle = self._load_bundle(bundle_dir)
+ except Exception as exc:
+ logger.warning(
+ "Skipping exporter SSH bundle '%s': %s",
+ bundle_dir.name,
+ exc,
+ )
+ continue
+
+ if bundle.alias in prepared:
+ logger.warning(
+ "Skipping duplicate exporter SSH bundle alias '%s'",
+ bundle.alias,
+ )
+ continue
+
+ prepared[bundle.alias] = bundle
+
+ if not prepared:
+ logger.info("No valid exporter SSH bundles were loaded")
+ return
+
+ self._write_managed_assets(prepared)
+ self._prepared_bundles = prepared
+ logger.info(
+ "Prepared exporter SSH runtime for %d exporter bundle(s)",
+ len(prepared),
+ )
+
+ def _load_bundle(self, bundle_dir: Path) -> PreparedExporterSSHBundle:
+ bundle_file = bundle_dir / "exporter.yaml"
+ if not bundle_file.is_file():
+ raise FileNotFoundError(f"missing {bundle_file.name}")
+
+ data = yaml.safe_load(bundle_file.read_text(encoding="utf-8")) or {}
+ try:
+ bundle = ExporterSSHBundleConfig.model_validate(data)
+ except ValidationError as exc:
+ raise ValueError(str(exc)) from exc
+
+ auth = bundle.ssh.auth
+ key_source: Optional[Path] = None
+
+ if auth.method == "private_key":
+ private_key_path = auth.private_key_path
+ if not private_key_path:
+ private_key_path = self._infer_private_key_path(bundle_dir)
+
+ if not private_key_path:
+ raise ValueError(
+ "private_key auth requires 'private_key_path' or a default key file"
+ )
+
+ key_source = (bundle_dir / private_key_path).resolve()
+ if not key_source.is_file():
+ raise FileNotFoundError(
+ f"private key '{private_key_path}' not found in bundle"
+ )
+
+ return PreparedExporterSSHBundle(
+ alias=bundle.alias,
+ host=bundle.host,
+ port=bundle.port,
+ user=bundle.ssh.resolve_user(),
+ auth_method=auth.method or "private_key",
+ known_hosts=bundle.known_hosts,
+ private_key_source=key_source,
+ password=auth.password,
+ password_env=auth.password_env,
+ )
+
+ def _infer_private_key_path(self, bundle_dir: Path) -> Optional[str]:
+ for candidate in DEFAULT_PRIVATE_KEY_CANDIDATES:
+ candidate_path = bundle_dir / candidate
+ if candidate_path.is_file():
+ return candidate
+ return None
+
+ def _write_managed_assets(
+ self,
+ bundles: Dict[str, PreparedExporterSSHBundle],
+ ) -> None:
+ home_ssh_dir = Path.home() / ".ssh"
+ managed_dir = self._managed_dir_override or home_ssh_dir / MANAGED_DIR_NAME
+ keys_dir = managed_dir / "keys"
+ bin_dir = managed_dir / "bin"
+ auth_file = managed_dir / "auth.json"
+ managed_config = managed_dir / "config"
+ managed_known_hosts = managed_dir / "known_hosts"
+ real_ssh = self._resolve_real_ssh()
+
+ home_ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+ shutil.rmtree(managed_dir, ignore_errors=True)
+ keys_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+ bin_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+
+ config_lines = ["# Managed by Labgrid Dashboard", ""]
+ known_hosts_lines: List[str] = []
+ auth_metadata: Dict[str, Dict[str, str]] = {}
+
+ for alias, bundle in sorted(bundles.items()):
+ key_target: Optional[Path] = None
+ if bundle.private_key_source is not None:
+ key_target = keys_dir / alias
+ shutil.copyfile(bundle.private_key_source, key_target)
+ key_target.chmod(0o600)
+
+ known_hosts_lines.extend(bundle.known_hosts)
+
+ config_lines.extend(self._render_host_block(bundle, key_target))
+ config_lines.append("")
+
+ auth_entry: Dict[str, str] = {"auth_method": bundle.auth_method}
+ if bundle.password is not None:
+ auth_entry["password"] = bundle.password
+ if bundle.password_env:
+ auth_entry["password_env"] = bundle.password_env
+ auth_metadata[alias] = auth_entry
+
+ managed_config.write_text("\n".join(config_lines).rstrip() + "\n", encoding="utf-8")
+ managed_config.chmod(0o644)
+ managed_known_hosts.write_text(
+ "\n".join(dict.fromkeys(known_hosts_lines)).rstrip() + "\n",
+ encoding="utf-8",
+ )
+ managed_known_hosts.chmod(0o644)
+ auth_file.write_text(
+ json.dumps(auth_metadata, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ auth_file.chmod(0o600)
+
+ wrapper_path = bin_dir / "ssh"
+ wrapper_path.write_text(_build_ssh_wrapper_script(), encoding="utf-8")
+ wrapper_path.chmod(0o755)
+ self._install_wrapper(wrapper_path)
+
+ self._ensure_main_config_includes_managed_config(home_ssh_dir / "config")
+
+ os.environ[AUTH_FILE_ENV] = str(auth_file)
+ os.environ[REAL_SSH_ENV] = real_ssh
+ os.environ["PATH"] = self._prepend_path(str(bin_dir), os.environ.get("PATH", ""))
+
+ def _render_host_block(
+ self,
+ bundle: PreparedExporterSSHBundle,
+ key_target: Optional[Path],
+ ) -> List[str]:
+ lines = [
+ f"Host {bundle.alias}",
+ f" HostName {bundle.host}",
+ f" Port {bundle.port}",
+ f" User {bundle.user}",
+ f" HostKeyAlias {bundle.alias}",
+ " StrictHostKeyChecking yes",
+ f" UserKnownHostsFile {self._managed_known_hosts_path()}",
+ ]
+
+ if bundle.auth_method == "private_key" and key_target is not None:
+ lines.extend(
+ [
+ f" IdentityFile {key_target}",
+ " IdentitiesOnly yes",
+ " PreferredAuthentications publickey",
+ " PasswordAuthentication no",
+ ]
+ )
+ else:
+ lines.extend(
+ [
+ " PubkeyAuthentication no",
+ " PreferredAuthentications password,keyboard-interactive",
+ " PasswordAuthentication yes",
+ ]
+ )
+
+ return lines
+
+ def _managed_known_hosts_path(self) -> Path:
+ managed_dir = self._managed_dir_override or (Path.home() / ".ssh" / MANAGED_DIR_NAME)
+ return managed_dir / "known_hosts"
+
+ def _ensure_main_config_includes_managed_config(self, config_path: Path) -> None:
+ include_line = MANAGED_CONFIG_INCLUDE
+ if config_path.exists():
+ content = config_path.read_text(encoding="utf-8")
+ if include_line in content:
+ return
+ suffix = "" if content.endswith("\n") else "\n"
+ config_path.write_text(
+ f"{content}{suffix}{include_line}\n",
+ encoding="utf-8",
+ )
+ else:
+ config_path.write_text(f"{include_line}\n", encoding="utf-8")
+
+ config_path.chmod(0o600)
+
+ def _prepend_path(self, entry: str, existing_path: str) -> str:
+ if not existing_path:
+ return entry
+
+ parts = existing_path.split(":")
+ if entry in parts:
+ return existing_path
+ return f"{entry}:{existing_path}"
+
+ def _resolve_real_ssh(self) -> str:
+ preferred_real_ssh = Path("/usr/bin/ssh")
+ if preferred_real_ssh.is_file():
+ return str(preferred_real_ssh)
+
+ ssh_path = shutil.which("ssh")
+ if ssh_path:
+ if self._wrapper_install_path and Path(ssh_path) == self._wrapper_install_path:
+ fallback = shutil.which("ssh", path="/usr/bin:/bin:/usr/sbin:/sbin")
+ if fallback:
+ return fallback
+ return ssh_path
+
+ return str(preferred_real_ssh)
+
+ def _install_wrapper(self, wrapper_path: Path) -> None:
+ if self._wrapper_install_path is None:
+ return
+
+ self._wrapper_install_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(wrapper_path, self._wrapper_install_path)
+ self._wrapper_install_path.chmod(0o755)
+
+
+def _build_ssh_wrapper_script() -> str:
+ return f"""#!{sys.executable}
+import json
+import os
+import shutil
+import sys
+
+OPTIONS_WITH_ARGUMENT = {{
+ "-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L",
+ "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
+}}
+AUTH_FILE_ENV = "{AUTH_FILE_ENV}"
+REAL_SSH_ENV = "{REAL_SSH_ENV}"
+
+
+def extract_host(arguments):
+ index = 0
+ end_of_options = False
+ while index < len(arguments):
+ arg = arguments[index]
+ if end_of_options:
+ return arg
+
+ if arg == "--":
+ end_of_options = True
+ index += 1
+ continue
+
+ if not arg.startswith("-") or arg == "-":
+ return arg
+
+ if arg in OPTIONS_WITH_ARGUMENT:
+ index += 2
+ continue
+
+ option_prefix = arg[:2]
+ if option_prefix in OPTIONS_WITH_ARGUMENT and len(arg) > 2:
+ index += 1
+ continue
+
+ index += 1
+
+ return None
+
+
+def normalize_host(host):
+ if host is None:
+ return None
+ if "@" in host:
+ host = host.split("@", 1)[1]
+ return host
+
+
+def rewrite_password_args(arguments):
+ rewritten = []
+ index = 0
+ while index < len(arguments):
+ arg = arguments[index]
+ if arg == "-o" and index + 1 < len(arguments):
+ option = arguments[index + 1]
+ lowered = option.lower()
+ if lowered.startswith("passwordauthentication="):
+ index += 2
+ continue
+ if lowered.startswith("pubkeyauthentication="):
+ index += 2
+ continue
+ if lowered.startswith("preferredauthentications="):
+ index += 2
+ continue
+ rewritten.extend([arg, option])
+ index += 2
+ continue
+
+ rewritten.append(arg)
+ index += 1
+
+ rewritten.extend([
+ "-o", "PasswordAuthentication=yes",
+ "-o", "PubkeyAuthentication=no",
+ "-o", "PreferredAuthentications=password,keyboard-interactive",
+ ])
+ return rewritten
+
+
+def main():
+ auth_file = os.environ.get(AUTH_FILE_ENV)
+ if not auth_file:
+ os.execv(os.environ.get(REAL_SSH_ENV, "/usr/bin/ssh"), [os.environ.get(REAL_SSH_ENV, "/usr/bin/ssh"), *sys.argv[1:]])
+
+ try:
+ with open(auth_file, "r", encoding="utf-8") as handle:
+ metadata = json.load(handle)
+ except Exception:
+ metadata = {{}}
+
+ host = normalize_host(extract_host(sys.argv[1:]))
+ entry = metadata.get(host or "")
+ real_ssh = os.environ.get(REAL_SSH_ENV, "/usr/bin/ssh")
+
+ if not entry or entry.get("auth_method") != "password":
+ os.execv(real_ssh, [real_ssh, *sys.argv[1:]])
+
+ password = entry.get("password")
+ password_env = entry.get("password_env")
+ if password is None and password_env:
+ password = os.environ.get(password_env)
+
+ if not password:
+ sys.stderr.write(
+ f"Exporter SSH password is not configured for '{{host}}'\\n"
+ )
+ raise SystemExit(255)
+
+ sshpass = shutil.which("sshpass")
+ if sshpass is None:
+ sys.stderr.write("sshpass is required for exporter password authentication\\n")
+ raise SystemExit(255)
+
+ rewritten_args = rewrite_password_args(sys.argv[1:])
+ os.execv(sshpass, [sshpass, "-p", password, real_ssh, *rewritten_args])
+
+
+if __name__ == "__main__":
+ main()
+"""
diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py
index 3a38be6..1c8d520 100644
--- a/backend/app/services/scheduler_service.py
+++ b/backend/app/services/scheduler_service.py
@@ -98,7 +98,7 @@ def set_execute_callback(self, callback: Callable) -> None:
"""Set the callback for executing commands on targets.
Args:
- callback: Async function(target_name, command) -> (output, exit_code)
+ callback: Async function(target_name, command) -> result object or tuple
"""
self._execute_callback = callback
@@ -293,9 +293,20 @@ async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
# Execute with lock to prevent concurrent access
async with target_lock:
try:
- output, exit_code = await self._execute_callback(
+ result = await self._execute_callback(
target.name, cmd.command
)
+ execution_transport = None
+ if isinstance(result, tuple):
+ output, exit_code = result
+ else:
+ output = result.output
+ exit_code = result.exit_code
+ execution_transport = getattr(
+ result,
+ "execution_transport",
+ None,
+ )
# Store the output
scheduled_output = ScheduledCommandOutput(
@@ -303,6 +314,7 @@ async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
output=output.strip() if output else "",
timestamp=datetime.now(timezone.utc),
exit_code=exit_code,
+ execution_transport=execution_transport,
)
if cmd.name not in self._outputs:
diff --git a/backend/tests/test_exporter_ssh_runtime.py b/backend/tests/test_exporter_ssh_runtime.py
new file mode 100644
index 0000000..f046492
--- /dev/null
+++ b/backend/tests/test_exporter_ssh_runtime.py
@@ -0,0 +1,224 @@
+"""
+Tests for exporter SSH runtime preparation.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+from app.services.exporter_ssh_runtime import (
+ AUTH_FILE_ENV,
+ REAL_SSH_ENV,
+ ExporterSSHRuntimeService,
+)
+
+
+def _write_file(path: Path, content: str, mode: int = 0o644) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ path.chmod(mode)
+
+
+def _write_executable(path: Path, content: str) -> None:
+ _write_file(path, content, mode=0o755)
+
+
+def test_exporter_ssh_runtime_generates_managed_assets(monkeypatch, tmp_path: Path):
+ """Bundle setup should render config, known_hosts, keys, and wrapper metadata."""
+ home = tmp_path / "home"
+ bundles = tmp_path / "bundles"
+ fake_bin = tmp_path / "fake-bin"
+ monkeypatch.setenv("HOME", str(home))
+
+ _write_file(
+ bundles / "exporter-key" / "exporter.yaml",
+ """
+alias: exporter-key
+host: 10.0.0.11
+ssh:
+ user: exporter
+ auth:
+ method: private_key
+known_hosts:
+ - "exporter-key ssh-ed25519 AAAAKEY"
+""".strip()
+ + "\n",
+ )
+ _write_file(
+ bundles / "exporter-key" / "id_ed25519",
+ "PRIVATE KEY",
+ mode=0o600,
+ )
+ _write_file(
+ bundles / "exporter-password" / "exporter.yaml",
+ """
+alias: exporter-password
+host: 10.0.0.12
+ssh:
+ user: root
+ auth:
+ method: password
+ password_env: EXPORTER_PASSWORD
+known_hosts:
+ - "exporter-password ssh-ed25519 AAAAPASSWORD"
+""".strip()
+ + "\n",
+ )
+
+ service = ExporterSSHRuntimeService(
+ str(bundles),
+ wrapper_install_path=str(fake_bin / "ssh"),
+ )
+ service.setup()
+
+ managed_dir = home / ".ssh" / "labgrid-dashboard"
+ assert (managed_dir / "config").exists()
+ assert (managed_dir / "known_hosts").exists()
+ assert (managed_dir / "bin" / "ssh").exists()
+ assert (managed_dir / "keys" / "exporter-key").read_text(encoding="utf-8") == "PRIVATE KEY"
+
+ config_text = (managed_dir / "config").read_text(encoding="utf-8")
+ assert "Host exporter-key" in config_text
+ assert "IdentityFile" in config_text
+ assert "Host exporter-password" in config_text
+ assert "PreferredAuthentications password,keyboard-interactive" in config_text
+
+ known_hosts_text = (managed_dir / "known_hosts").read_text(encoding="utf-8")
+ assert "exporter-key ssh-ed25519 AAAAKEY" in known_hosts_text
+ assert "exporter-password ssh-ed25519 AAAAPASSWORD" in known_hosts_text
+
+ home_config = (home / ".ssh" / "config").read_text(encoding="utf-8")
+ assert "Include ~/.ssh/labgrid-dashboard/config" in home_config
+ assert os.environ[AUTH_FILE_ENV] == str(managed_dir / "auth.json")
+ assert str(managed_dir / "bin") in os.environ["PATH"].split(":")
+
+
+def test_exporter_ssh_wrapper_uses_sshpass_for_password_exporters(
+ monkeypatch,
+ tmp_path: Path,
+):
+ """Password-authenticated exporters should route through sshpass."""
+ home = tmp_path / "home"
+ bundles = tmp_path / "bundles"
+ fake_bin = tmp_path / "fake-bin"
+ monkeypatch.setenv("HOME", str(home))
+ monkeypatch.setenv("EXPORTER_PASSWORD", "s3cr3t")
+ monkeypatch.setenv("PATH", str(fake_bin))
+
+ _write_file(
+ bundles / "exporter-password" / "exporter.yaml",
+ """
+alias: exporter-password
+host: 10.0.0.12
+ssh:
+ user: root
+ auth:
+ method: password
+ password_env: EXPORTER_PASSWORD
+known_hosts:
+ - "exporter-password ssh-ed25519 AAAAPASSWORD"
+""".strip()
+ + "\n",
+ )
+
+ _write_executable(
+ fake_bin / "sshpass",
+ "#!/bin/sh\nprintf '%s\\n' \"$@\"\n",
+ )
+ _write_executable(
+ fake_bin / "ssh-real",
+ "#!/bin/sh\nprintf '%s\\n' \"$@\"\n",
+ )
+
+ install_wrapper = fake_bin / "ssh"
+ service = ExporterSSHRuntimeService(
+ str(bundles),
+ wrapper_install_path=str(install_wrapper),
+ )
+ service.setup()
+
+ wrapper = home / ".ssh" / "labgrid-dashboard" / "bin" / "ssh"
+ env = os.environ.copy()
+ env[REAL_SSH_ENV] = str(fake_bin / "ssh-real")
+
+ result = subprocess.run(
+ [
+ str(wrapper),
+ "-x",
+ "-o",
+ "PasswordAuthentication=no",
+ "exporter-password",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+ output = result.stdout
+ assert "-p" in output
+ assert "s3cr3t" in output
+ assert "PasswordAuthentication=no" not in output
+ assert "PasswordAuthentication=yes" in output
+ assert "exporter-password" in output
+ assert install_wrapper.read_text(encoding="utf-8") == wrapper.read_text(encoding="utf-8")
+
+
+def test_exporter_ssh_wrapper_passthrough_for_private_key_exporters(
+ monkeypatch,
+ tmp_path: Path,
+):
+ """Key-authenticated exporters should call the real ssh binary unchanged."""
+ home = tmp_path / "home"
+ bundles = tmp_path / "bundles"
+ fake_bin = tmp_path / "fake-bin"
+ monkeypatch.setenv("HOME", str(home))
+ monkeypatch.setenv("PATH", str(fake_bin))
+
+ _write_file(
+ bundles / "exporter-key" / "exporter.yaml",
+ """
+alias: exporter-key
+host: 10.0.0.11
+ssh:
+ auth:
+ method: private_key
+known_hosts:
+ - "exporter-key ssh-ed25519 AAAAKEY"
+""".strip()
+ + "\n",
+ )
+ _write_file(
+ bundles / "exporter-key" / "id_ed25519",
+ "PRIVATE KEY",
+ mode=0o600,
+ )
+
+ _write_executable(
+ fake_bin / "ssh-real",
+ "#!/bin/sh\nprintf '%s\\n' \"$@\"\n",
+ )
+
+ install_wrapper = fake_bin / "ssh"
+ service = ExporterSSHRuntimeService(
+ str(bundles),
+ wrapper_install_path=str(install_wrapper),
+ )
+ service.setup()
+
+ wrapper = home / ".ssh" / "labgrid-dashboard" / "bin" / "ssh"
+ env = os.environ.copy()
+ env[REAL_SSH_ENV] = str(fake_bin / "ssh-real")
+
+ result = subprocess.run(
+ [str(wrapper), "-x", "exporter-key"],
+ check=True,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+ assert result.stdout.splitlines() == ["-x", "exporter-key"]
+ assert install_wrapper.read_text(encoding="utf-8") == wrapper.read_text(encoding="utf-8")
diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py
index 9f826a4..8b09505 100644
--- a/backend/tests/test_main.py
+++ b/backend/tests/test_main.py
@@ -11,8 +11,14 @@
reconnect_coordinator_in_background,
sync_coordinator_runtime,
)
-from app.models.target import CommandExecutionConfig, SerialCommandExecutionConfig
+from app.models.target import (
+ CommandExecutionConfig,
+ CommandOutput,
+ SerialCommandExecutionConfig,
+ Target,
+)
from app.services.command_execution_service import (
+ CommandExecutionResult,
CommandExecutionService,
TransportExecutionError,
)
@@ -101,16 +107,20 @@ async def test_command_execution_service_prefers_serial_then_ssh():
preset_service=preset_service,
)
execution_service._try_execute_via_serial = AsyncMock(
- return_value=("serial ok", 0)
+ return_value=CommandExecutionResult("serial ok", 0, "serial")
+ )
+ execution_service._try_execute_via_ssh = AsyncMock(
+ return_value=CommandExecutionResult("ssh ok", 0, "ssh")
)
- execution_service._execute_via_ssh = AsyncMock(return_value=("ssh ok", 0))
- output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+ result = await execution_service.execute_command("dut-1", "echo test")
+ output, exit_code = result
assert output == "serial ok"
assert exit_code == 0
+ assert result.execution_transport == "serial"
execution_service._try_execute_via_serial.assert_awaited_once()
- execution_service._execute_via_ssh.assert_not_awaited()
+ execution_service._try_execute_via_ssh.assert_not_awaited()
labgrid_client.acquire_target.assert_awaited_once_with("dut-1")
labgrid_client.release_target_with_retry.assert_awaited_once_with("dut-1")
@@ -143,15 +153,18 @@ async def test_command_execution_service_falls_back_to_ssh_when_serial_unavailab
preset_service=preset_service,
)
execution_service._try_execute_via_serial = AsyncMock(return_value=None)
- execution_service._execute_via_ssh = AsyncMock(return_value=("ssh ok", 0))
- execution_service._has_available_ssh_resource = MagicMock(return_value=True)
+ execution_service._try_execute_via_ssh = AsyncMock(
+ return_value=CommandExecutionResult("ssh ok", 0, "ssh")
+ )
- output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+ result = await execution_service.execute_command("dut-1", "echo test")
+ output, exit_code = result
assert output == "ssh ok"
assert exit_code == 0
+ assert result.execution_transport == "ssh"
execution_service._try_execute_via_serial.assert_awaited_once()
- execution_service._execute_via_ssh.assert_awaited_once()
+ execution_service._try_execute_via_ssh.assert_awaited_once()
@pytest.mark.asyncio
@@ -180,18 +193,24 @@ async def test_command_execution_service_falls_back_to_ssh_when_serial_transport
command_service=command_service,
preset_service=preset_service,
)
+ execution_service._reset_target_proxy_connections = MagicMock()
execution_service._try_execute_via_serial = AsyncMock(
side_effect=TransportExecutionError("serial login failed")
)
- execution_service._execute_via_ssh = AsyncMock(return_value=("ssh ok", 0))
- execution_service._has_available_ssh_resource = MagicMock(return_value=True)
+ execution_service._try_execute_via_ssh = AsyncMock(
+ return_value=CommandExecutionResult("ssh ok", 0, "ssh")
+ )
- output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+ result = await execution_service.execute_command("dut-1", "echo test")
+ output, exit_code = result
assert output == "ssh ok"
assert exit_code == 0
+ assert result.execution_transport == "ssh"
execution_service._try_execute_via_serial.assert_awaited_once()
- execution_service._execute_via_ssh.assert_awaited_once()
+ execution_service._try_execute_via_ssh.assert_awaited_once()
+ assert execution_service._reset_target_proxy_connections.call_count == 2
+ execution_service._reset_target_proxy_connections.assert_called_with("dut-1")
@pytest.mark.asyncio
@@ -220,11 +239,49 @@ async def test_command_execution_service_returns_serial_error_when_no_fallback_e
command_service=command_service,
preset_service=preset_service,
)
+ execution_service._reset_target_proxy_connections = MagicMock()
execution_service._try_execute_via_serial = AsyncMock(
side_effect=TransportExecutionError("serial login failed")
)
- output, exit_code = await execution_service.execute_command("dut-1", "echo test")
+ result = await execution_service.execute_command("dut-1", "echo test")
+ output, exit_code = result
assert output == "Error: serial login failed"
assert exit_code == 1
+ assert result.execution_transport is None
+
+
+def test_command_execution_service_enrich_target_includes_cached_outputs():
+ """Manual command outputs should be reattached when targets are enriched."""
+ execution_service = CommandExecutionService(
+ labgrid_client=MagicMock(),
+ command_service=MagicMock(),
+ preset_service=MagicMock(),
+ )
+ execution_service.get_target_command_state = MagicMock(
+ return_value=(True, "ssh", None)
+ )
+ execution_service.record_output(
+ "dut-1",
+ CommandOutput(
+ command="cat /etc/os-release",
+ output="ok",
+ exit_code=0,
+ execution_transport="ssh",
+ ),
+ )
+
+ target = Target(
+ name="dut-1",
+ status="available",
+ acquired_by=None,
+ resources=[],
+ )
+
+ enriched = execution_service.enrich_target(target)
+
+ assert enriched.command_capable is True
+ assert enriched.command_transport == "ssh"
+ assert len(enriched.last_command_outputs) == 1
+ assert enriched.last_command_outputs[0].execution_transport == "ssh"
diff --git a/backend/tests/test_targets.py b/backend/tests/test_targets.py
index 1ccaf9e..5b6544e 100644
--- a/backend/tests/test_targets.py
+++ b/backend/tests/test_targets.py
@@ -8,6 +8,7 @@
from httpx import AsyncClient
from app.api.routes.targets import set_command_execution_service
+from app.services.command_execution_service import CommandExecutionResult
from app.services.labgrid_client import TargetAcquiredByOtherError
@@ -135,7 +136,9 @@ async def test_execute_command_uses_command_execution_service(client: AsyncClien
execution_service = MagicMock()
execution_service.enrich_target.side_effect = lambda target: target
execution_service.enrich_targets.side_effect = lambda targets: targets
- execution_service.execute_command = AsyncMock(return_value=("serial output", 0))
+ execution_service.execute_command = AsyncMock(
+ return_value=CommandExecutionResult("serial output", 0, "serial")
+ )
set_command_execution_service(execution_service)
try:
@@ -149,6 +152,8 @@ async def test_execute_command_uses_command_execution_service(client: AsyncClien
assert response.status_code == 200
data = response.json()
assert data["output"] == "serial output"
+ assert data["execution_transport"] == "serial"
+ execution_service.record_output.assert_called_once()
execution_service.execute_command.assert_awaited_once_with(
"test-dut-1",
"echo test",
From 1846adff7a31bfdf00f879c79599d582aea72b6b Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:19:58 +0100
Subject: [PATCH 09/20] test(staging): add four-exporter transport matrix
---
backend/commands.yaml | 69 ++++++++++++
backend/target_presets.json | 9 +-
docker-compose.yml | 97 ++++++++++++++---
docker/dut/Dockerfile | 14 ++-
docker/dut/entrypoint.sh | 36 ++++++-
docker/dut/serial-console.sh | 31 ++++++
docker/exporter/Dockerfile | 13 ++-
docker/exporter/entrypoint.sh | 101 ++++++++++++++++--
docker/exporter/exporter-config.yaml | 10 +-
docker/init-acquire/Dockerfile | 14 +--
docker/init-acquire/acquire-exporter.sh | 32 ++++--
.../dut-server/exporter-3/authorized_keys | 1 +
docker/staging/dut-ssh/exporter-3/id_ed25519 | 7 ++
.../staging/dut-ssh/exporter-3/id_ed25519.pub | 1 +
.../exporter-1/authorized_keys | 1 +
.../exporter-1/ssh_host_ed25519_key | 7 ++
.../exporter-1/ssh_host_ed25519_key.pub | 1 +
.../exporter-2/ssh_host_ed25519_key | 7 ++
.../exporter-2/ssh_host_ed25519_key.pub | 1 +
.../exporter-3/authorized_keys | 1 +
.../exporter-3/ssh_host_ed25519_key | 7 ++
.../exporter-3/ssh_host_ed25519_key.pub | 1 +
.../exporter-4/ssh_host_ed25519_key | 7 ++
.../exporter-4/ssh_host_ed25519_key.pub | 1 +
.../exporter-ssh/exporter-1/exporter.yaml | 11 ++
.../exporter-ssh/exporter-1/id_ed25519 | 7 ++
.../exporter-ssh/exporter-1/id_ed25519.pub | 1 +
.../exporter-ssh/exporter-2/exporter.yaml | 12 +++
.../exporter-ssh/exporter-3/exporter.yaml | 11 ++
.../exporter-ssh/exporter-3/id_ed25519 | 7 ++
.../exporter-ssh/exporter-3/id_ed25519.pub | 1 +
.../exporter-ssh/exporter-4/exporter.yaml | 12 +++
32 files changed, 481 insertions(+), 50 deletions(-)
create mode 100644 docker/dut/serial-console.sh
create mode 100644 docker/staging/dut-server/exporter-3/authorized_keys
create mode 100644 docker/staging/dut-ssh/exporter-3/id_ed25519
create mode 100644 docker/staging/dut-ssh/exporter-3/id_ed25519.pub
create mode 100644 docker/staging/exporter-server/exporter-1/authorized_keys
create mode 100644 docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key
create mode 100644 docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key.pub
create mode 100644 docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key
create mode 100644 docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key.pub
create mode 100644 docker/staging/exporter-server/exporter-3/authorized_keys
create mode 100644 docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key
create mode 100644 docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key.pub
create mode 100644 docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key
create mode 100644 docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key.pub
create mode 100644 docker/staging/exporter-ssh/exporter-1/exporter.yaml
create mode 100644 docker/staging/exporter-ssh/exporter-1/id_ed25519
create mode 100644 docker/staging/exporter-ssh/exporter-1/id_ed25519.pub
create mode 100644 docker/staging/exporter-ssh/exporter-2/exporter.yaml
create mode 100644 docker/staging/exporter-ssh/exporter-3/exporter.yaml
create mode 100644 docker/staging/exporter-ssh/exporter-3/id_ed25519
create mode 100644 docker/staging/exporter-ssh/exporter-3/id_ed25519.pub
create mode 100644 docker/staging/exporter-ssh/exporter-4/exporter.yaml
diff --git a/backend/commands.yaml b/backend/commands.yaml
index 120ed19..5305ae2 100644
--- a/backend/commands.yaml
+++ b/backend/commands.yaml
@@ -115,3 +115,72 @@ presets:
command: "cat /proc/loadavg | cut -d' ' -f1-3"
interval_seconds: 180
description: "System load average (updates every 3 minutes)"
+
+ staging_serial_only:
+ name: "Staging Serial Only"
+ description: "Staging preset that executes commands only over the serial console"
+ command_execution:
+ transport_order:
+ - serial
+ serial:
+ prompt: ".*[#\\$] "
+ login_prompt: "(?i)login: ?"
+ username: "root"
+ password: "labgrid"
+ commands:
+ - name: "Linux Version"
+ command: "cat /etc/os-release"
+ description: "Shows the Linux distribution"
+ - name: "System Time"
+ command: "date"
+ description: "Current system time"
+
+ staging_serial_fallback:
+ name: "Staging Serial With SSH Fallback"
+ description: "Staging preset that tries serial first and falls back to SSH"
+ command_execution:
+ transport_order:
+ - serial
+ - ssh
+ serial:
+ login_prompt: "(?i)login: ?"
+ username: "root"
+ password: "labgrid"
+ prompt: ".*[#\\$] "
+ commands:
+ - name: "Linux Version"
+ command: "cat /etc/os-release"
+ description: "Shows the Linux distribution"
+ - name: "System Time"
+ command: "date"
+ description: "Current system time"
+
+ staging_ssh_key:
+ name: "Staging SSH With Key"
+ description: "Staging preset that executes commands over SSH using a private key"
+ command_execution:
+ transport_order:
+ - ssh
+ ssh:
+ keyfile_env: "EXPORTER_3_DUT_SSH_KEYFILE"
+ commands:
+ - name: "Linux Version"
+ command: "cat /etc/os-release"
+ description: "Shows the Linux distribution"
+ - name: "System Time"
+ command: "date"
+ description: "Current system time"
+
+ staging_ssh_password:
+ name: "Staging SSH With Password"
+ description: "Staging preset that executes commands over SSH using username/password"
+ command_execution:
+ transport_order:
+ - ssh
+ commands:
+ - name: "Linux Version"
+ command: "cat /etc/os-release"
+ description: "Shows the Linux distribution"
+ - name: "System Time"
+ command: "date"
+ description: "Current system time"
diff --git a/backend/target_presets.json b/backend/target_presets.json
index 9e26dfe..b279b20 100644
--- a/backend/target_presets.json
+++ b/backend/target_presets.json
@@ -1 +1,8 @@
-{}
\ No newline at end of file
+{
+ "assignments": {
+ "exporter-1": "staging_serial_only",
+ "exporter-2": "staging_serial_fallback",
+ "exporter-3": "staging_ssh_key",
+ "exporter-4": "staging_ssh_password"
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index a0485d7..95a7b65 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,7 @@
#
# Profiles:
# - default (no profile): Runs coordinator, backend, frontend
-# - staging: Adds 3 DUTs and exporters for realistic testing
+# - staging: Adds 4 DUTs and exporters for realistic testing
services:
# =============================================================================
@@ -46,11 +46,18 @@ services:
- COORDINATOR_REALM=realm1
- COORDINATOR_TIMEOUT=30
- PYTHONUNBUFFERED=1
+ - EXPORTER_SSH_BUNDLES_DIR=/app/exporter-ssh
+ - EXPORTER_2_SSH_PASSWORD=exporter-2-password
+ - EXPORTER_4_SSH_PASSWORD=exporter-4-password
+ - EXPORTER_3_DUT_SSH_KEYFILE=/root/.ssh/labgrid-dashboard/keys/exporter-3
depends_on:
coordinator:
condition: service_healthy
networks:
- labgrid-network
+ volumes:
+ - ./docker/staging/exporter-ssh:/app/exporter-ssh:ro
+ - ./docker/staging/dut-ssh:/app/dut-ssh:ro
restart: unless-stopped
# React Frontend - Development server with Vite
@@ -113,6 +120,26 @@ services:
profiles: ["staging"]
environment:
- DUT_NAME=dut-3
+ - DUT_SSH_AUTH_MODE=private_key
+ - DUT_SSH_SERVER_DIR=/ssh-server
+ networks:
+ - labgrid-network
+ volumes:
+ - ./docker/staging/dut-server/exporter-3:/ssh-server:ro
+ restart: unless-stopped
+
+ # DUT 4 - Simulated device with serial-over-TCP
+ dut-4:
+ build:
+ context: ./docker/dut
+ dockerfile: Dockerfile
+ container_name: labgrid-dut-4
+ hostname: dut-4
+ profiles: ["staging"]
+ environment:
+ - DUT_NAME=dut-4
+ - DUT_SSH_AUTH_MODE=password
+ - DUT_SSH_PASSWORD=labgrid
networks:
- labgrid-network
restart: unless-stopped
@@ -134,14 +161,19 @@ services:
- DUT_PORT=5000
- EXPORTER_NAME=exporter-1
- COORDINATOR_URL=ws://coordinator:20408/ws
+ - EXPORTER_ISOLATED=1
+ - EXPORTER_HOSTNAME=exporter-1
+ - EXPORTER_SSH_AUTH_MODE=private_key
depends_on:
- dut-1
- coordinator
networks:
- labgrid-network
+ volumes:
+ - ./docker/staging/exporter-server/exporter-1:/ssh-server:ro
restart: unless-stopped
- # Exporter 2 - Exports DUT-2 to Labgrid Coordinator
+ # Exporter 2 - Exports DUT-2 to Labgrid Coordinator with password auth
exporter-2:
build:
context: ./docker/exporter
@@ -152,16 +184,23 @@ services:
environment:
- DUT_HOST=dut-2
- DUT_PORT=5000
+ - SERIAL_PORT=5999
- EXPORTER_NAME=exporter-2
- COORDINATOR_URL=ws://coordinator:20408/ws
+ - EXPORTER_ISOLATED=1
+ - EXPORTER_HOSTNAME=exporter-2
+ - EXPORTER_SSH_AUTH_MODE=password
+ - EXPORTER_SSH_PASSWORD=exporter-2-password
depends_on:
- dut-2
- coordinator
networks:
- labgrid-network
+ volumes:
+ - ./docker/staging/exporter-server/exporter-2:/ssh-server:ro
restart: unless-stopped
- # Exporter 3 - Exports DUT-3 to Labgrid Coordinator
+ # Exporter 3 - Exports DUT-3 to Labgrid Coordinator with key auth
exporter-3:
build:
context: ./docker/exporter
@@ -172,35 +211,65 @@ services:
environment:
- DUT_HOST=dut-3
- DUT_PORT=5000
+ - SSH_PASSWORD=
- EXPORTER_NAME=exporter-3
- COORDINATOR_URL=ws://coordinator:20408/ws
+ - EXPORTER_ISOLATED=1
+ - EXPORTER_HOSTNAME=exporter-3
+ - EXPORTER_SSH_AUTH_MODE=private_key
+ - EXPORTER_SSH_SERVER_DIR=/ssh-server
depends_on:
- dut-3
- coordinator
networks:
- labgrid-network
+ volumes:
+ - ./docker/staging/exporter-server/exporter-3:/ssh-server:ro
+ restart: unless-stopped
+
+ # Exporter 4 - Exports DUT-4 to Labgrid Coordinator with password auth
+ exporter-4:
+ build:
+ context: ./docker/exporter
+ dockerfile: Dockerfile
+ container_name: labgrid-exporter-4
+ hostname: exporter-4
+ profiles: ["staging"]
+ environment:
+ - DUT_HOST=dut-4
+ - DUT_PORT=5000
+ - EXPORTER_NAME=exporter-4
+ - COORDINATOR_URL=ws://coordinator:20408/ws
+ - EXPORTER_ISOLATED=1
+ - EXPORTER_HOSTNAME=exporter-4
+ - EXPORTER_SSH_AUTH_MODE=password
+ - EXPORTER_SSH_PASSWORD=exporter-4-password
+ depends_on:
+ - dut-4
+ - coordinator
+ networks:
+ - labgrid-network
+ volumes:
+ - ./docker/staging/exporter-server/exporter-4:/ssh-server:ro
restart: unless-stopped
# =============================================================================
- # Staging Profile: Init Container for Auto-Acquire
+ # Staging Profile: Init Container for Place Setup
# =============================================================================
- # Init-Acquire - Automatically acquires exporter-1 for demonstration
- # This runs once at startup to show "acquired" status in the dashboard
- init-acquire:
+ # Init-Places - Creates all exporter places without acquiring any of them
+ init-places:
build:
context: ./docker/init-acquire
dockerfile: Dockerfile
- container_name: labgrid-init-acquire
+ container_name: labgrid-init-places
profiles: ["staging"]
environment:
# Coordinator connection (host:port format for labgrid-client)
- COORDINATOR_HOST=coordinator:20408
- # Place name to create and acquire
- - PLACE_NAME=exporter-1
- # Exporter to match resources from
- - EXPORTER_NAME=exporter-1
- # Username shown in dashboard as "acquired_by"
+ # Leave empty to avoid acquiring any place by default
+ - ACQUIRE_PLACE=none
+ # Username used for labgrid-client operations
- USER_NAME=staging-user
# Wait time for services to initialize
- WAIT_TIME=15
@@ -213,6 +282,8 @@ services:
condition: service_started
exporter-3:
condition: service_started
+ exporter-4:
+ condition: service_started
networks:
- labgrid-network
# Restart policy: no (runs once then exits)
diff --git a/docker/dut/Dockerfile b/docker/dut/Dockerfile
index 8f04492..4b1c0dc 100644
--- a/docker/dut/Dockerfile
+++ b/docker/dut/Dockerfile
@@ -14,19 +14,23 @@ RUN apk add --no-cache \
# Setup SSH server
RUN ssh-keygen -A && \
- mkdir -p /run/sshd && \
- echo "root:labgrid" | chpasswd && \
- sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && \
- sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
+ mkdir -p /run/sshd /root/.ssh && \
+ echo "root:labgrid" | chpasswd
# Serial-over-TCP listener script
COPY entrypoint.sh /entrypoint.sh
-RUN chmod +x /entrypoint.sh
+COPY serial-console.sh /serial-console.sh
+RUN chmod +x /entrypoint.sh /serial-console.sh
# Expose serial port and SSH port
EXPOSE 5000 22
# Environment variable for device name (optional)
ENV DUT_NAME=dut
+ENV DUT_SSH_AUTH_MODE=password
+ENV DUT_SSH_PASSWORD=labgrid
+ENV DUT_SSH_SERVER_DIR=/ssh-server
+ENV DUT_SERIAL_USERNAME=root
+ENV DUT_SERIAL_PASSWORD=labgrid
CMD ["/entrypoint.sh"]
diff --git a/docker/dut/entrypoint.sh b/docker/dut/entrypoint.sh
index 11d1e0e..8476d1d 100644
--- a/docker/dut/entrypoint.sh
+++ b/docker/dut/entrypoint.sh
@@ -9,6 +9,38 @@
echo "Starting DUT simulator: ${DUT_NAME:-dut}"
+configure_dut_ssh() {
+ mkdir -p /run/sshd /root/.ssh
+ chmod 700 /root/.ssh
+
+ sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
+ sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
+ sed -i 's/#PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
+
+ case "${DUT_SSH_AUTH_MODE:-password}" in
+ private_key)
+ if [ ! -f "${DUT_SSH_SERVER_DIR}/authorized_keys" ]; then
+ echo "Missing authorized_keys in ${DUT_SSH_SERVER_DIR} for ${DUT_NAME}"
+ exit 1
+ fi
+ cp "${DUT_SSH_SERVER_DIR}/authorized_keys" /root/.ssh/authorized_keys
+ chmod 600 /root/.ssh/authorized_keys
+ sed -i 's/^PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
+ ;;
+ password)
+ echo "root:${DUT_SSH_PASSWORD:-labgrid}" | chpasswd
+ rm -f /root/.ssh/authorized_keys
+ sed -i 's/^PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
+ ;;
+ *)
+ echo "Unsupported DUT_SSH_AUTH_MODE '${DUT_SSH_AUTH_MODE}'"
+ exit 1
+ ;;
+ esac
+}
+
+configure_dut_ssh
+
# Start SSH server in background
echo "Starting SSH server on port 22..."
/usr/sbin/sshd -D &
@@ -21,5 +53,5 @@ echo "Listening on port 5000 for serial-over-TCP connections..."
# Start socat to listen on TCP port 5000
# - TCP-LISTEN: Listen on port 5000, reuse address, fork for each connection
-# - EXEC: Execute bash as login shell with PTY
-exec socat TCP-LISTEN:5000,reuseaddr,fork EXEC:'/bin/bash -li',pty,stderr,setsid,sigint,sane
+# - EXEC: Execute the staging serial login shell with PTY
+exec socat TCP-LISTEN:5000,reuseaddr,fork EXEC:'/serial-console.sh',pty,stderr,setsid,sigint,sane
diff --git a/docker/dut/serial-console.sh b/docker/dut/serial-console.sh
new file mode 100644
index 0000000..b4012d0
--- /dev/null
+++ b/docker/dut/serial-console.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+# Minimal serial login shell for staging.
+
+set -eu
+
+expected_user="${DUT_SERIAL_USERNAME:-root}"
+expected_password="${DUT_SERIAL_PASSWORD:-labgrid}"
+
+while true; do
+ printf "login: "
+ if ! IFS= read -r user; then
+ exit 0
+ fi
+
+ printf "Password: "
+ if ! IFS= read -r password; then
+ exit 0
+ fi
+ printf "\n"
+
+ if [ "$user" != "$expected_user" ] || [ "$password" != "$expected_password" ]; then
+ printf "Login incorrect\n\n"
+ continue
+ fi
+
+ export HOME="/root"
+ export USER="$expected_user"
+ export LOGNAME="$expected_user"
+ export PS1="# "
+ exec /bin/bash -li
+done
diff --git a/docker/exporter/Dockerfile b/docker/exporter/Dockerfile
index f4d6a55..32cd5f9 100644
--- a/docker/exporter/Dockerfile
+++ b/docker/exporter/Dockerfile
@@ -4,10 +4,14 @@
FROM python:3.11-slim
# Install labgrid and dependencies
-RUN pip install --no-cache-dir labgrid
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ openssh-server \
+ && rm -rf /var/lib/apt/lists/* && \
+ pip install --no-cache-dir labgrid
# Create config directory
-RUN mkdir -p /config
+RUN mkdir -p /config /run/sshd /root/.ssh
# Copy configuration template
COPY exporter-config.yaml /config/exporter-template.yaml
@@ -21,6 +25,11 @@ ENV DUT_HOST=dut
ENV DUT_PORT=5000
ENV EXPORTER_NAME=exporter
ENV COORDINATOR_URL=ws://coordinator:20408/ws
+ENV EXPORTER_ISOLATED=0
+ENV EXPORTER_HOSTNAME=
+ENV EXPORTER_SSH_AUTH_MODE=none
+ENV EXPORTER_SSH_PASSWORD=
+ENV EXPORTER_SSH_SERVER_DIR=/ssh-server
# Working directory for exporter
WORKDIR /config
diff --git a/docker/exporter/entrypoint.sh b/docker/exporter/entrypoint.sh
index bed99ee..2565c9c 100644
--- a/docker/exporter/entrypoint.sh
+++ b/docker/exporter/entrypoint.sh
@@ -1,34 +1,117 @@
-#!/bin/bash
+#!/bin/sh
# Labgrid Exporter Entrypoint Script
-# Generates configuration from template and starts the exporter
+# Generates configuration from template and starts the exporter.
-set -e
+set -eu
echo "Starting Labgrid Exporter: ${EXPORTER_NAME}"
echo " DUT Host: ${DUT_HOST}"
echo " DUT Port: ${DUT_PORT}"
echo " Coordinator: ${COORDINATOR_URL}"
-# Generate exporter configuration from template
+SERIAL_HOST="${SERIAL_HOST:-${DUT_HOST}}"
+SERIAL_PORT="${SERIAL_PORT:-${DUT_PORT}}"
+SSH_ADDRESS="${SSH_ADDRESS:-${DUT_HOST}}"
+SSH_USERNAME="${SSH_USERNAME:-root}"
+SSH_PASSWORD="${SSH_PASSWORD-labgrid}"
+
echo "Generating exporter configuration..."
-sed -e "s/__DUT_HOST__/${DUT_HOST}/g" \
- -e "s/__DUT_PORT__/${DUT_PORT}/g" \
+sed -e "s/__SERIAL_HOST__/${SERIAL_HOST}/g" \
+ -e "s/__SERIAL_PORT__/${SERIAL_PORT}/g" \
+ -e "s/__SSH_ADDRESS__/${SSH_ADDRESS}/g" \
+ -e "s/__SSH_USERNAME__/${SSH_USERNAME}/g" \
+ -e "s/__SSH_PASSWORD__/${SSH_PASSWORD}/g" \
-e "s/__EXPORTER_NAME__/${EXPORTER_NAME}/g" \
/config/exporter-template.yaml > /config/exporter.yaml
echo "Generated configuration:"
cat /config/exporter.yaml
-# Wait for coordinator to be available
+configure_exporter_ssh() {
+ if [ "${EXPORTER_ISOLATED:-0}" != "1" ]; then
+ echo "Exporter SSH disabled for ${EXPORTER_NAME}; running in direct mode."
+ return
+ fi
+
+ echo "Configuring exporter SSH access for ${EXPORTER_NAME}..."
+ mkdir -p /run/sshd /root/.ssh
+ chmod 700 /root/.ssh
+
+ if [ -f "${EXPORTER_SSH_SERVER_DIR}/ssh_host_ed25519_key" ]; then
+ cp "${EXPORTER_SSH_SERVER_DIR}/ssh_host_ed25519_key" /etc/ssh/ssh_host_ed25519_key
+ chmod 600 /etc/ssh/ssh_host_ed25519_key
+ else
+ ssh-keygen -A >/dev/null 2>&1 || true
+ fi
+
+ if [ -f "${EXPORTER_SSH_SERVER_DIR}/ssh_host_ed25519_key.pub" ]; then
+ cp "${EXPORTER_SSH_SERVER_DIR}/ssh_host_ed25519_key.pub" /etc/ssh/ssh_host_ed25519_key.pub
+ chmod 644 /etc/ssh/ssh_host_ed25519_key.pub
+ fi
+
+ sed -i \
+ -e 's/^#\?PermitRootLogin .*/PermitRootLogin yes/' \
+ -e 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' \
+ -e 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' \
+ -e 's/^#\?UsePAM .*/UsePAM no/' \
+ /etc/ssh/sshd_config
+
+ case "${EXPORTER_SSH_AUTH_MODE:-none}" in
+ private_key)
+ if [ ! -f "${EXPORTER_SSH_SERVER_DIR}/authorized_keys" ]; then
+ echo "Missing authorized_keys for ${EXPORTER_NAME}"
+ exit 1
+ fi
+ cp "${EXPORTER_SSH_SERVER_DIR}/authorized_keys" /root/.ssh/authorized_keys
+ chmod 600 /root/.ssh/authorized_keys
+ ;;
+ password)
+ exporter_password="${EXPORTER_SSH_PASSWORD:-}"
+ if [ -z "${exporter_password}" ] && [ -n "${EXPORTER_SSH_PASSWORD_FILE:-}" ] && [ -f "${EXPORTER_SSH_PASSWORD_FILE}" ]; then
+ exporter_password="$(cat "${EXPORTER_SSH_PASSWORD_FILE}")"
+ fi
+ if [ -z "${exporter_password}" ]; then
+ echo "Missing EXPORTER_SSH_PASSWORD or EXPORTER_SSH_PASSWORD_FILE for ${EXPORTER_NAME}"
+ exit 1
+ fi
+ echo "root:${exporter_password}" | chpasswd
+ rm -f /root/.ssh/authorized_keys
+ sed -i \
+ -e 's/^PubkeyAuthentication .*/PubkeyAuthentication no/' \
+ -e 's/^PasswordAuthentication .*/PasswordAuthentication yes/' \
+ /etc/ssh/sshd_config
+ ;;
+ *)
+ echo "Unsupported EXPORTER_SSH_AUTH_MODE '${EXPORTER_SSH_AUTH_MODE}'"
+ exit 1
+ ;;
+ esac
+
+ echo "Starting SSH daemon for ${EXPORTER_NAME}..."
+ /usr/sbin/sshd -e
+}
+
+configure_exporter_ssh
+
echo "Waiting for coordinator at ${COORDINATOR_URL}..."
sleep 5
-# Start the labgrid exporter
-# -c HOST:PORT format requires extracting host:port from ws://host:port/ws URL
COORDINATOR_HOST_PORT=$(echo "${COORDINATOR_URL}" | sed 's|ws://||' | sed 's|/ws$||')
echo "Starting labgrid-exporter..."
echo " Coordinator: ${COORDINATOR_HOST_PORT}"
+
+if [ "${EXPORTER_ISOLATED:-0}" = "1" ]; then
+ EXPORTER_RUNTIME_HOSTNAME="${EXPORTER_HOSTNAME:-${EXPORTER_NAME}}"
+ echo " Isolated mode: enabled (${EXPORTER_RUNTIME_HOSTNAME})"
+ exec labgrid-exporter -c "${COORDINATOR_HOST_PORT}" \
+ --name "${EXPORTER_NAME}" \
+ --isolated \
+ --hostname "${EXPORTER_RUNTIME_HOSTNAME}" \
+ /config/exporter.yaml
+fi
+
+echo " Isolated mode: disabled"
exec labgrid-exporter -c "${COORDINATOR_HOST_PORT}" \
--name "${EXPORTER_NAME}" \
/config/exporter.yaml
diff --git a/docker/exporter/exporter-config.yaml b/docker/exporter/exporter-config.yaml
index 07b99a8..34b82e2 100644
--- a/docker/exporter/exporter-config.yaml
+++ b/docker/exporter/exporter-config.yaml
@@ -1,10 +1,10 @@
__EXPORTER_NAME__:
NetworkSerialPort:
- host: "__DUT_HOST__"
- port: __DUT_PORT__
+ host: "__SERIAL_HOST__"
+ port: __SERIAL_PORT__
speed: 115200
protocol: "raw"
NetworkService:
- address: "__DUT_HOST__"
- username: "root"
- password: "labgrid"
+ address: "__SSH_ADDRESS__"
+ username: "__SSH_USERNAME__"
+ password: "__SSH_PASSWORD__"
diff --git a/docker/init-acquire/Dockerfile b/docker/init-acquire/Dockerfile
index 0ff0b7f..f72474a 100644
--- a/docker/init-acquire/Dockerfile
+++ b/docker/init-acquire/Dockerfile
@@ -1,8 +1,8 @@
-# Init-Acquire Container
-# Automatically acquires exporter-1 place for staging demonstration
+# Init-Places Container
+# Creates exporter places for staging demonstration.
#
-# This container runs once at startup to create and acquire a place,
-# demonstrating the "acquired" status in the dashboard.
+# This container runs once at startup to create place mappings and can
+# optionally acquire a place if ACQUIRE_PLACE is set.
FROM python:3.11-slim
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir git+https://github.com/labgrid-project/labgrid.gi
# Show available labgrid-client commands for debugging
RUN labgrid-client --help || true
-# Copy the acquire script
+# Copy the place setup script
COPY acquire-exporter.sh /scripts/acquire-exporter.sh
RUN chmod +x /scripts/acquire-exporter.sh
@@ -26,10 +26,10 @@ WORKDIR /scripts
# Default environment variables
ENV COORDINATOR_HOST=coordinator:20408
-ENV PLACE_NAME=exporter-1
+ENV ACQUIRE_PLACE=none
ENV EXPORTER_NAME=exporter-1
ENV USER_NAME=staging-user
ENV WAIT_TIME=10
-# Run the acquire script
+# Run the place setup script
CMD ["/scripts/acquire-exporter.sh"]
diff --git a/docker/init-acquire/acquire-exporter.sh b/docker/init-acquire/acquire-exporter.sh
index 8596a6c..2f0d80b 100644
--- a/docker/init-acquire/acquire-exporter.sh
+++ b/docker/init-acquire/acquire-exporter.sh
@@ -1,19 +1,19 @@
#!/bin/bash
-# Auto-acquire exporter-1 for staging demonstration
-# This script creates places for ALL exporters, matches them to exporter resources,
-# and acquires only exporter-1 for the staging user.
+# Create places for staging exporters and optionally acquire one place.
+# This script creates places for all exporters, matches them to exporter resources,
+# and can optionally acquire one place for demonstration.
# Uses labgrid-client CLI from latest labgrid version
COORDINATOR_HOST="${COORDINATOR_HOST:-coordinator:20408}"
-ACQUIRE_PLACE="${ACQUIRE_PLACE:-exporter-1}"
+ACQUIRE_PLACE="${ACQUIRE_PLACE:-none}"
USER_NAME="${USER_NAME:-staging-user}"
WAIT_TIME="${WAIT_TIME:-10}"
# Define all exporters to create places for
-EXPORTERS=("exporter-1" "exporter-2" "exporter-3")
+EXPORTERS=("exporter-1" "exporter-2" "exporter-3" "exporter-4")
echo "=========================================="
-echo "Labgrid Auto-Acquire Script"
+echo "Labgrid Place Setup Script"
echo "=========================================="
echo "Coordinator: ${COORDINATOR_HOST}"
echo "Exporters: ${EXPORTERS[*]}"
@@ -87,6 +87,26 @@ for exporter in "${EXPORTERS[@]}"; do
}
done
+echo ""
+echo "Step 8: Releasing all places to ensure a clean staging baseline..."
+for exporter in "${EXPORTERS[@]}"; do
+ echo " Releasing place '${exporter}' if needed..."
+ labgrid-client -p "${exporter}" release 2>&1 || true
+done
+
+echo ""
+if [ -z "${ACQUIRE_PLACE}" ] || [ "${ACQUIRE_PLACE}" = "none" ]; then
+ echo ""
+ echo "=========================================="
+ echo "SUCCESS: Setup complete!"
+ echo "=========================================="
+ echo "Places created: ${EXPORTERS[*]}"
+ echo "No place acquired by default."
+ echo "=========================================="
+ sleep 2
+ exit 0
+fi
+
echo ""
echo "Step 8: Acquiring place '${ACQUIRE_PLACE}'..."
if labgrid-client -p "${ACQUIRE_PLACE}" acquire 2>&1; then
diff --git a/docker/staging/dut-server/exporter-3/authorized_keys b/docker/staging/dut-server/exporter-3/authorized_keys
new file mode 100644
index 0000000..77b0edc
--- /dev/null
+++ b/docker/staging/dut-server/exporter-3/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHJkb7cLYacnwSAI0SOL+g2ifTnU2OuHHOZTaz413XLG exporter-3-client
diff --git a/docker/staging/dut-ssh/exporter-3/id_ed25519 b/docker/staging/dut-ssh/exporter-3/id_ed25519
new file mode 100644
index 0000000..952340b
--- /dev/null
+++ b/docker/staging/dut-ssh/exporter-3/id_ed25519
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACByZG+3C2GnJ8EgCNEji/oNon051NjrhxzmU2s+Nd1yxgAAAJg3FO6TNxTu
+kwAAAAtzc2gtZWQyNTUxOQAAACByZG+3C2GnJ8EgCNEji/oNon051NjrhxzmU2s+Nd1yxg
+AAAEBOOsuCoQl8s2eYyQv6RET8vIoaRFdV2fttLu9PqqZm/HJkb7cLYacnwSAI0SOL+g2i
+fTnU2OuHHOZTaz413XLGAAAAEWV4cG9ydGVyLTMtY2xpZW50AQIDBA==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/dut-ssh/exporter-3/id_ed25519.pub b/docker/staging/dut-ssh/exporter-3/id_ed25519.pub
new file mode 100644
index 0000000..77b0edc
--- /dev/null
+++ b/docker/staging/dut-ssh/exporter-3/id_ed25519.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHJkb7cLYacnwSAI0SOL+g2ifTnU2OuHHOZTaz413XLG exporter-3-client
diff --git a/docker/staging/exporter-server/exporter-1/authorized_keys b/docker/staging/exporter-server/exporter-1/authorized_keys
new file mode 100644
index 0000000..204e828
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-1/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSDdBugEIVH3Z6OA3Uk6hu7grE+f6gn6O+nXCPYyUqo exporter-1-client
diff --git a/docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key b/docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key
new file mode 100644
index 0000000..5939cb3
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACA7PKxfQcPAb6c6nds1rIZVWWqoVeqAfKCxIkJ1mA5VowAAAJjDrd/cw63f
+3AAAAAtzc2gtZWQyNTUxOQAAACA7PKxfQcPAb6c6nds1rIZVWWqoVeqAfKCxIkJ1mA5Vow
+AAAED/Mf8cHIVfItE0FdEnN5D0IX5BEsBg9r+urzjSRimS4zs8rF9Bw8Bvpzqd2zWshlVZ
+aqhV6oB8oLEiQnWYDlWjAAAAD2V4cG9ydGVyLTEtaG9zdAECAwQFBg==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key.pub b/docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key.pub
new file mode 100644
index 0000000..365505e
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-1/ssh_host_ed25519_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs8rF9Bw8Bvpzqd2zWshlVZaqhV6oB8oLEiQnWYDlWj exporter-1-host
diff --git a/docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key b/docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key
new file mode 100644
index 0000000..27524dc
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACCzdrzAMRRBGSO3VmkiY1ZXt3a0fOf2FFwafDVAoMVsqgAAAJg0PSQOND0k
+DgAAAAtzc2gtZWQyNTUxOQAAACCzdrzAMRRBGSO3VmkiY1ZXt3a0fOf2FFwafDVAoMVsqg
+AAAEAnU4HCcEEj+3cRYWZldv5wNBlcXPieBYRH0ai0/WpLSLN2vMAxFEEZI7dWaSJjVle3
+drR85/YUXBp8NUCgxWyqAAAAD2V4cG9ydGVyLTItaG9zdAECAwQFBg==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key.pub b/docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key.pub
new file mode 100644
index 0000000..8a84c93
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-2/ssh_host_ed25519_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN2vMAxFEEZI7dWaSJjVle3drR85/YUXBp8NUCgxWyq exporter-2-host
diff --git a/docker/staging/exporter-server/exporter-3/authorized_keys b/docker/staging/exporter-server/exporter-3/authorized_keys
new file mode 100644
index 0000000..77b0edc
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-3/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHJkb7cLYacnwSAI0SOL+g2ifTnU2OuHHOZTaz413XLG exporter-3-client
diff --git a/docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key b/docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key
new file mode 100644
index 0000000..f6ca19f
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACCv4/05XvA8FhwIvb225+tZzmJtuJrJOrh//BuQz+tUFgAAAJj6a3V1+mt1
+dQAAAAtzc2gtZWQyNTUxOQAAACCv4/05XvA8FhwIvb225+tZzmJtuJrJOrh//BuQz+tUFg
+AAAECh7b+CJGcxLUq3n4zkej12BzERpSc7EoSem6+ICcxmXq/j/Tle8DwWHAi9vbbn61nO
+Ym24msk6uH/8G5DP61QWAAAAD2V4cG9ydGVyLTMtaG9zdAECAwQFBg==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key.pub b/docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key.pub
new file mode 100644
index 0000000..ca1eb51
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-3/ssh_host_ed25519_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK/j/Tle8DwWHAi9vbbn61nOYm24msk6uH/8G5DP61QW exporter-3-host
diff --git a/docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key b/docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key
new file mode 100644
index 0000000..f18f16f
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACCS5JN/rF4X/8YmgF0aM0DB1qOrGl0iFd0cnqZyONmtkwAAAJil2IMSpdiD
+EgAAAAtzc2gtZWQyNTUxOQAAACCS5JN/rF4X/8YmgF0aM0DB1qOrGl0iFd0cnqZyONmtkw
+AAAEDVySgvJM/sKIRS4ATTOjPUp3Difar/dJLKF2nxewuTwpLkk3+sXhf/xiaAXRozQMHW
+o6saXSIV3RyepnI42a2TAAAAD2V4cG9ydGVyLTQtaG9zdAECAwQFBg==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key.pub b/docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key.pub
new file mode 100644
index 0000000..c803efc
--- /dev/null
+++ b/docker/staging/exporter-server/exporter-4/ssh_host_ed25519_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJLkk3+sXhf/xiaAXRozQMHWo6saXSIV3RyepnI42a2T exporter-4-host
diff --git a/docker/staging/exporter-ssh/exporter-1/exporter.yaml b/docker/staging/exporter-ssh/exporter-1/exporter.yaml
new file mode 100644
index 0000000..84c56b4
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-1/exporter.yaml
@@ -0,0 +1,11 @@
+alias: exporter-1
+host: exporter-1
+port: 22
+
+ssh:
+ user: root
+ auth:
+ method: private_key
+
+known_hosts:
+ - "exporter-1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs8rF9Bw8Bvpzqd2zWshlVZaqhV6oB8oLEiQnWYDlWj"
diff --git a/docker/staging/exporter-ssh/exporter-1/id_ed25519 b/docker/staging/exporter-ssh/exporter-1/id_ed25519
new file mode 100644
index 0000000..16ad6de
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-1/id_ed25519
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACAUg3QboBCFR92ejgN1JOobu4KxPn+oJ+jvp1wj2MlKqAAAAJjAKIUiwCiF
+IgAAAAtzc2gtZWQyNTUxOQAAACAUg3QboBCFR92ejgN1JOobu4KxPn+oJ+jvp1wj2MlKqA
+AAAEDWkGSfy6ON1SSpSqZx3Qiwb3PGQ/5WZ3I6lS+zgb4SpBSDdBugEIVH3Z6OA3Uk6hu7
+grE+f6gn6O+nXCPYyUqoAAAAEWV4cG9ydGVyLTEtY2xpZW50AQIDBA==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/exporter-ssh/exporter-1/id_ed25519.pub b/docker/staging/exporter-ssh/exporter-1/id_ed25519.pub
new file mode 100644
index 0000000..204e828
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-1/id_ed25519.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSDdBugEIVH3Z6OA3Uk6hu7grE+f6gn6O+nXCPYyUqo exporter-1-client
diff --git a/docker/staging/exporter-ssh/exporter-2/exporter.yaml b/docker/staging/exporter-ssh/exporter-2/exporter.yaml
new file mode 100644
index 0000000..827f004
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-2/exporter.yaml
@@ -0,0 +1,12 @@
+alias: exporter-2
+host: exporter-2
+port: 22
+
+ssh:
+ user: root
+ auth:
+ method: password
+ password_env: EXPORTER_2_SSH_PASSWORD
+
+known_hosts:
+ - "exporter-2 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN2vMAxFEEZI7dWaSJjVle3drR85/YUXBp8NUCgxWyq"
diff --git a/docker/staging/exporter-ssh/exporter-3/exporter.yaml b/docker/staging/exporter-ssh/exporter-3/exporter.yaml
new file mode 100644
index 0000000..2dbfb9d
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-3/exporter.yaml
@@ -0,0 +1,11 @@
+alias: exporter-3
+host: exporter-3
+port: 22
+
+ssh:
+ user: root
+ auth:
+ method: private_key
+
+known_hosts:
+ - "exporter-3 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK/j/Tle8DwWHAi9vbbn61nOYm24msk6uH/8G5DP61QW"
diff --git a/docker/staging/exporter-ssh/exporter-3/id_ed25519 b/docker/staging/exporter-ssh/exporter-3/id_ed25519
new file mode 100644
index 0000000..952340b
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-3/id_ed25519
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACByZG+3C2GnJ8EgCNEji/oNon051NjrhxzmU2s+Nd1yxgAAAJg3FO6TNxTu
+kwAAAAtzc2gtZWQyNTUxOQAAACByZG+3C2GnJ8EgCNEji/oNon051NjrhxzmU2s+Nd1yxg
+AAAEBOOsuCoQl8s2eYyQv6RET8vIoaRFdV2fttLu9PqqZm/HJkb7cLYacnwSAI0SOL+g2i
+fTnU2OuHHOZTaz413XLGAAAAEWV4cG9ydGVyLTMtY2xpZW50AQIDBA==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/docker/staging/exporter-ssh/exporter-3/id_ed25519.pub b/docker/staging/exporter-ssh/exporter-3/id_ed25519.pub
new file mode 100644
index 0000000..77b0edc
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-3/id_ed25519.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHJkb7cLYacnwSAI0SOL+g2ifTnU2OuHHOZTaz413XLG exporter-3-client
diff --git a/docker/staging/exporter-ssh/exporter-4/exporter.yaml b/docker/staging/exporter-ssh/exporter-4/exporter.yaml
new file mode 100644
index 0000000..8698b8d
--- /dev/null
+++ b/docker/staging/exporter-ssh/exporter-4/exporter.yaml
@@ -0,0 +1,12 @@
+alias: exporter-4
+host: exporter-4
+port: 22
+
+ssh:
+ user: root
+ auth:
+ method: password
+ password_env: EXPORTER_4_SSH_PASSWORD
+
+known_hosts:
+ - "exporter-4 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJLkk3+sXhf/xiaAXRozQMHWo6saXSIV3RyepnI42a2T"
From 579e872d7f7ef0d3e31f30bbbfc3096bcfabf882 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:20:02 +0100
Subject: [PATCH 10/20] feat(frontend): show actual command transport
---
frontend/src/App.tsx | 12 +----
frontend/src/__tests__/TargetTable.test.tsx | 33 ++++++++++++-
.../components/CommandPanel/CommandPanel.css | 10 ++++
.../components/CommandPanel/OutputViewer.tsx | 48 ++++++++++++-------
.../src/components/TargetTable/TargetRow.tsx | 38 ++++++++++++++-
frontend/src/hooks/useWebSocket.ts | 15 ++++--
frontend/src/types/index.ts | 2 +
7 files changed, 123 insertions(+), 35 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f3e2f92..a191077 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -92,25 +92,17 @@ function App() {
(
targetName: string,
commandName: string,
- output: CommandOutput,
+ output: ScheduledCommandOutput,
) => {
console.log(
`Scheduled output for ${targetName} (${commandName}):`,
output.output,
);
- // Convert CommandOutput to ScheduledCommandOutput format
- const scheduledOutput: ScheduledCommandOutput = {
- command_name: commandName,
- output: output.output,
- timestamp: output.timestamp,
- exit_code: output.exit_code,
- };
-
const applied = updateTargetScheduledOutput(
targetName,
commandName,
- scheduledOutput,
+ output,
);
if (!applied) {
diff --git a/frontend/src/__tests__/TargetTable.test.tsx b/frontend/src/__tests__/TargetTable.test.tsx
index 71ba5f2..f56c30d 100644
--- a/frontend/src/__tests__/TargetTable.test.tsx
+++ b/frontend/src/__tests__/TargetTable.test.tsx
@@ -43,7 +43,15 @@ const mockTargets: Target[] = [
params: { host: "192.168.1.100", port: 4001 },
},
],
- last_command_outputs: [],
+ last_command_outputs: [
+ {
+ command: "echo test",
+ output: "test",
+ timestamp: new Date().toISOString(),
+ exit_code: 0,
+ execution_transport: "ssh",
+ },
+ ],
scheduled_outputs: {},
command_capable: true,
command_transport: "serial",
@@ -175,10 +183,31 @@ describe("TargetTable", () => {
expect(screen.getByText("Commands for test-dut-1")).toBeInTheDocument();
});
- expect(screen.getByText("Command transport: serial")).toBeInTheDocument();
+ expect(screen.getByText("Command transport: ssh")).toBeInTheDocument();
+ expect(
+ screen.getByTitle("Transport used for this execution"),
+ ).toHaveTextContent("ssh");
expect(screen.getByRole("button", { name: "Test Command" })).toBeInTheDocument();
});
+ it("falls back to the target-level transport when no command output transport is available", async () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /expand details/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Commands for test-dut-2")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Command transport: ssh")).toBeInTheDocument();
+ });
+
it("hides the command panel and shows an error for incapable targets", async () => {
const api = await import("../services/api");
const getCommandsSpy = vi.mocked(api.api.getCommands);
diff --git a/frontend/src/components/CommandPanel/CommandPanel.css b/frontend/src/components/CommandPanel/CommandPanel.css
index 232f889..6a8d7d7 100644
--- a/frontend/src/components/CommandPanel/CommandPanel.css
+++ b/frontend/src/components/CommandPanel/CommandPanel.css
@@ -245,6 +245,16 @@
color: #9ca3af;
}
+.transport-label {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+ background-color: rgba(59, 130, 246, 0.18);
+ color: #60a5fa;
+ font-weight: 500;
+}
+
.output-content {
margin: 0;
padding: 1rem;
diff --git a/frontend/src/components/CommandPanel/OutputViewer.tsx b/frontend/src/components/CommandPanel/OutputViewer.tsx
index e8a1073..24b217f 100644
--- a/frontend/src/components/CommandPanel/OutputViewer.tsx
+++ b/frontend/src/components/CommandPanel/OutputViewer.tsx
@@ -1,4 +1,4 @@
-import type { CommandOutput } from '../../types';
+import type { CommandOutput, ExecutionTransport } from '../../types';
interface OutputViewerProps {
outputs: CommandOutput[];
@@ -22,27 +22,39 @@ export function OutputViewer({ outputs, maxHeight = '300px' }: OutputViewerProps
return date.toLocaleString();
};
+ const getTransportLabel = (output: CommandOutput): ExecutionTransport | null =>
+ output.execution_transport ?? null;
+
return (
- {outputs.map((output, index) => (
-
-
-
$ {output.command}
-
-
- {output.exit_code === 0 ? '✓' : '✗'} {output.exit_code}
-
-
- {formatTimestamp(output.timestamp)}
-
+ {outputs.map((output, index) => {
+ const transport = getTransportLabel(output);
+
+ return (
+
+
+
$ {output.command}
+
+ {transport && (
+
+ {transport}
+
+ )}
+
+ {output.exit_code === 0 ? '✓' : '✗'} {output.exit_code}
+
+
+ {formatTimestamp(output.timestamp)}
+
+
+
{output.output}
-
{output.output}
-
- ))}
+ );
+ })}
);
}
diff --git a/frontend/src/components/TargetTable/TargetRow.tsx b/frontend/src/components/TargetTable/TargetRow.tsx
index e4057ad..21d0420 100644
--- a/frontend/src/components/TargetTable/TargetRow.tsx
+++ b/frontend/src/components/TargetTable/TargetRow.tsx
@@ -1,6 +1,12 @@
import { useState, useCallback } from "react";
import { createPortal } from "react-dom";
-import type { Target, CommandOutput, ScheduledCommand } from "../../types";
+import type {
+ Target,
+ CommandOutput,
+ ScheduledCommand,
+ ScheduledCommandOutput,
+ ExecutionTransport,
+} from "../../types";
import { StatusBadge } from "./StatusBadge";
import { CommandPanel } from "../CommandPanel";
import { TargetSettings } from "../TargetSettings";
@@ -116,6 +122,34 @@ export function TargetRow({
onCommandOutputsChange?.(target.name, outputs);
};
+ const getTransportLabel = () => {
+ const getOutputTransport = (
+ output:
+ | CommandOutput
+ | ScheduledCommandOutput
+ | null
+ | undefined,
+ ): ExecutionTransport | null => output?.execution_transport ?? null;
+
+ const latestManualOutput =
+ (commandOutputs && commandOutputs.length > 0
+ ? commandOutputs[0]
+ : target.last_command_outputs[0]) ?? null;
+
+ const latestScheduledOutput =
+ Object.values(target.scheduled_outputs ?? {}).sort(
+ (left, right) =>
+ Date.parse(right.timestamp) - Date.parse(left.timestamp),
+ )[0] ?? null;
+
+ return (
+ getOutputTransport(latestManualOutput) ??
+ getOutputTransport(latestScheduledOutput) ??
+ target.command_transport ??
+ null
+ );
+ };
+
const renderIpAddress = () => {
if (!target.ip_address) {
return
- ;
@@ -277,7 +311,7 @@ export function TargetRow({
(target.status === "offline"
? "Commands unavailable - target is offline"
: "Commands unavailable for this target");
- const commandTransport = target.command_transport ?? null;
+ const commandTransport = getTransportLabel();
return (
<>
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts
index e9e8d07..ec68ec8 100644
--- a/frontend/src/hooks/useWebSocket.ts
+++ b/frontend/src/hooks/useWebSocket.ts
@@ -1,5 +1,10 @@
import { useState, useEffect, useCallback, useRef } from 'react';
-import type { WSMessage, Target, CommandOutput } from '../types';
+import type {
+ WSMessage,
+ Target,
+ CommandOutput,
+ ScheduledCommandOutput,
+} from '../types';
import { buildWsUrl } from '../utils/urlBuilder';
const RECONNECT_INTERVAL = 5000;
@@ -8,7 +13,11 @@ const MAX_RECONNECT_ATTEMPTS = 10;
interface UseWebSocketOptions {
onTargetUpdate?: (target: Target) => void;
onCommandOutput?: (targetName: string, output: CommandOutput) => void;
- onScheduledOutput?: (targetName: string, commandName: string, output: CommandOutput) => void;
+ onScheduledOutput?: (
+ targetName: string,
+ commandName: string,
+ output: ScheduledCommandOutput,
+ ) => void;
onTargetsList?: (targets: Target[]) => void;
onConnectionChange?: (connected: boolean) => void;
}
@@ -86,7 +95,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRes
const data = message.data as {
target: string;
command_name: string;
- output: CommandOutput;
+ output: ScheduledCommandOutput;
};
callbacksRef.current.onScheduledOutput?.(
data.target,
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 7f8a814..88c2890 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -14,6 +14,7 @@ export interface CommandOutput {
output: string;
timestamp: string;
exit_code: number;
+ execution_transport?: ExecutionTransport | null;
}
/**
@@ -24,6 +25,7 @@ export interface ScheduledCommandOutput {
output: string;
timestamp: string;
exit_code: number;
+ execution_transport?: ExecutionTransport | null;
}
/**
From fc4b1f3f0a608620178b53ea67cbbb84792de145 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:20:09 +0100
Subject: [PATCH 11/20] docs: update exporter ssh and staging guidance
---
README.md | 92 ++++++++++++++++++++++++++++-----------
TESTING.md | 25 +++++++++++
docs/DEPLOYMENT.md | 78 +++++++++++++++++++++++++++++++--
docs/RELEASE-CHECKLIST.md | 9 ++++
quick-start.md | 15 ++++++-
5 files changed, 190 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index 61748a7..84816fd 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,10 @@ Labgrid Dashboard provides a real-time web interface to:
- **Monitor status** - See which devices are available, acquired, or offline
- **Track ownership** - Know who currently has acquired each exporter/target
- **Quick access** - Click on IP addresses to directly access device web interfaces
-- **Execute commands** - Run predefined commands on DUTs with serial-first transport and SSH fallback
+- **Execute commands** - Run predefined commands on DUTs with serial-first transport, exporter SSH bundles, and SSH fallback
- **Hardware Presets** - Assign hardware-specific command sets to different targets
- **Grouped Display** - Targets are automatically grouped by their preset type
-- **Transport-aware UI** - Hide command controls on unsupported targets and show a per-device reason
+- **Transport-aware UI** - Hide command controls on unsupported targets, show a per-device reason, and surface the transport actually used for each command
- **Real-time updates** - WebSocket-based live status updates without manual refresh
> 📖 For a quick introduction, see the [Quick Start Guide](quick-start.md).
@@ -60,7 +60,7 @@ Labgrid Dashboard provides a real-time web interface to:
docker pull ghcr.io/gerrri/labgrid-dashboard:latest
# Or pin to a specific version (recommended for production)
-docker pull ghcr.io/gerrri/labgrid-dashboard:1.0.0
+docker pull ghcr.io/gerrri/labgrid-dashboard:v0.1.4
```
### Quick Start
@@ -70,6 +70,7 @@ docker run -d \
--name labgrid-dashboard \
-p 80:80 \
-e COORDINATOR_URL=ws://your-coordinator:20408/ws \
+ -v ./exporter-ssh:/app/exporter-ssh:ro \
ghcr.io/gerrri/labgrid-dashboard:latest
```
@@ -124,6 +125,38 @@ The frontend normalizes runtime URL settings to avoid malformed paths:
- `API_URL`: `""`, `/`, `/api`, `/api/` all resolve correctly (no `/api/api/*`)
- `WS_URL`: relative and absolute values are normalized to a valid WebSocket URL
+### Exporter SSH Bundles
+
+Serial command execution still reaches exporters over SSH. The backend expects an exporter SSH bundle tree at runtime and generates the SSH material it needs from that input.
+
+Runtime input path:
+
+- `/app/exporter-ssh/
/exporter.yaml`
+- optional key files in the same exporter directory, such as `/app/exporter-ssh//id_ed25519`
+
+Generated runtime SSH material:
+
+- `~/.ssh/config` with an include for the managed exporter config
+- `~/.ssh/labgrid-dashboard/config`
+- `~/.ssh/labgrid-dashboard/known_hosts`
+- `~/.ssh/labgrid-dashboard/keys/` when a private key is provided
+
+Supported auth modes:
+
+- private key
+- username/password
+
+Example bundle layout:
+
+```text
+/app/exporter-ssh/
+ exporter-1/
+ exporter.yaml
+ id_ed25519
+ exporter-2/
+ exporter.yaml
+```
+
### Example: Production with Docker Compose
```yaml
@@ -139,6 +172,7 @@ services:
volumes:
- ./commands.yaml:/app/commands.yaml:ro
- ./target_presets.json:/app/target_presets.json:ro
+ - ./exporter-ssh:/app/exporter-ssh:ro
restart: unless-stopped
```
@@ -164,7 +198,7 @@ docker compose up -d
- Backend API: http://localhost:8000
### Staging Mode
-Runs with simulated DUTs (Alpine Linux containers) and real Labgrid Exporters. Commands prefer a serial shell and fall back to SSH if serial execution is not available for the target/preset.
+Runs with simulated DUTs (Alpine Linux containers) and real Labgrid exporters. Commands prefer a serial shell and still reach exporters over SSH through the exporter bundle configuration. If serial execution is not available for the target/preset, the backend falls back to SSH when the preset allows it.
If the backend starts before the coordinator becomes reachable, it remains available in degraded mode and retries the coordinator connection automatically in the background.
@@ -175,15 +209,20 @@ docker compose --profile staging up -d --build
**Ports**: Same as development mode
-**Auto-Acquire Feature:**
-When starting in staging mode, an init-container automatically:
-1. Creates a place named `exporter-1`
-2. Matches it with the exporter-1 resources
-3. Acquires the place as `staging-user`
+**Staging Topology:**
+The staging profile creates four places and leaves all of them idle by default:
+
+- `exporter-1`: serial-only command execution
+- `exporter-2`: serial-first command execution with SSH fallback
+- `exporter-3`: SSH command execution using a DUT private key
+- `exporter-4`: SSH command execution using DUT username/password
+
+The exporter SSH bundle coverage in staging also exercises both supported exporter auth modes:
+
+- `exporter-1`: exporter reached through a private key bundle
+- `exporter-4`: exporter reached through a username/password bundle
-This demonstrates the "acquired" status in the dashboard with:
-- `exporter-1`: Status "acquired", acquired_by: "staging-user"
-- `exporter-2`, `exporter-3`: Status "available"
+After running a command, the UI updates the `Command transport:` label to the transport that was actually used for the latest execution. For example, `exporter-2` switches from `serial` to `ssh` after the SSH fallback path succeeds.
**Staging Architecture:**
```
@@ -191,15 +230,15 @@ This demonstrates the "acquired" status in the dashboard with:
│ Staging Environment │
├─────────────────────────────────────────────────────────────┤
│ │
-│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
-│ │ DUT-1 │ │ DUT-2 │ │ DUT-3 │ (Alpine Linux) │
-│ │ :5000 │ │ :5000 │ │ :5000 │ │
-│ └────┬────┘ └────┬────┘ └────┬────┘ │
-│ │ Serial │ Serial │ Serial │
-│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
-│ │Exporter1│ │Exporter2│ │Exporter3│ (labgrid) │
-│ └────┬────┘ └────┬────┘ └────┬────┘ │
-│ └──────────┬──┴──────────────┘ gRPC │
+│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
+│ │ DUT-1 │ │ DUT-2 │ │ DUT-3 │ │ DUT-4 │ │
+│ │ :5000 │ │ :5000 │ │ :5000 │ │ :5000 │ │
+│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
+│ │ Serial │ Serial │ SSH │ SSH │
+│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
+│ │Exporter1│ │Exporter2│ │Exporter3│ │Exporter4│ │
+│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
+│ └─────────────┴─────────────┴─────────────┘ gRPC │
│ ┌─────▼─────┐ │
│ │Coordinator│ (labgrid 24.0+) │
│ └─────┬─────┘ │
@@ -218,13 +257,16 @@ This demonstrates the "acquired" status in the dashboard with:
1. Frontend sends command request to Backend via HTTP
2. Backend resolves the target's preset and `command_execution` transport order
3. Backend prefers serial execution through Labgrid's `SerialDriver` + `ShellDriver`
-4. If serial is unavailable or transport setup fails, Backend falls back to `labgrid-client ssh` when SSH is allowed by the preset
-5. Scheduled commands, REST requests, and WebSocket-triggered commands all use the same backend execution service
-6. Output flows back through the same path
+4. Serial command execution still reaches the exporter over SSH via the exporter bundle configuration
+5. If serial is unavailable or transport setup fails, Backend falls back to the backend-managed `SSHDriver` when SSH is allowed by the preset
+6. The frontend shows the transport that was actually used for the latest command result
+7. Scheduled commands, REST requests, and WebSocket-triggered commands all use the same backend execution service
+8. Output flows back through the same path
**Supported Execution Transports:**
- **Serial shell** - Uses a `NetworkSerialPort` and Labgrid's `ShellDriver`, including login automation and prompt detection
-- **SSH fallback** - Uses `labgrid-client ssh` when a `NetworkService` is available and the preset allows SSH
+- **Exporter SSH bundles** - Provide exporter host/IP, `known_hosts`, and optional key material for serial transport
+- **SSH fallback** - Uses the backend-managed `SSHDriver` when a `NetworkService` is available and the preset allows SSH
- **Unsupported** - If neither transport is available, the backend marks the target as not command-capable and the UI hides command controls for that device
### Command Execution Configuration
diff --git a/TESTING.md b/TESTING.md
index cb6e356..9c033bb 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -159,6 +159,7 @@ docker run -d \
--name labgrid-test \
-p 8080:80 \
-e COORDINATOR_URL=ws://your-coordinator:20408/ws \
+ -v ./exporter-ssh:/app/exporter-ssh:ro \
labgrid-dashboard:test
# Test endpoints
@@ -167,6 +168,26 @@ curl http://localhost:8080/api/health # Backend (requires coordinator)
curl http://localhost:8080/env-config.js # Runtime config
```
+## Exporter SSH Bundle Validation
+
+Use the staging profile to verify both supported exporter auth modes:
+
+```bash
+docker compose --profile staging up -d --build
+```
+
+Expected checks:
+- `exporter-1` executes commands over serial
+- `exporter-2` tries serial first and falls back to SSH
+- `exporter-3` executes commands over SSH using a DUT private key
+- `exporter-4` executes commands over SSH using DUT username/password
+- all four exporters start as `available` with no default acquisition after the coordinator cache settles
+- `~/.ssh/config` includes the managed exporter SSH snippet
+- `~/.ssh/labgrid-dashboard/config`, `~/.ssh/labgrid-dashboard/known_hosts`, and generated key files are present in the runtime container
+- serial command execution still reaches exporters over SSH before falling back to DUT SSH
+- the UI updates `Command transport:` to the transport actually used for the latest execution
+- after a reload, `/api/targets` still provides `last_command_outputs[*].execution_transport` for the newest manual command result
+
## Regression Testing
After all fixes, verify these still work:
@@ -176,6 +197,9 @@ After all fixes, verify these still work:
- ✅ WebSocket real-time updates
- ✅ Scheduled command columns
- ✅ Target settings dialog
+- ✅ Exporter SSH bundle loading
+- ✅ Serial command execution through exporter SSH for both private key and username/password auth
+- ✅ Actual transport display persists via cached manual command outputs
## Automated Test Summary
@@ -184,6 +208,7 @@ After all fixes, verify these still work:
| Frontend Build | ✅ PASS | TypeScript compilation successful |
| Backend Tests | ✅ PASS | 132/133 tests passed |
| Production Image | ✅ PASS | Docker build & runtime tests passed |
+| Exporter SSH Bundles | ✅ PASS | Staging exercises private key and username/password exporter bundles |
| Manual Testing | ⏳ TODO | Follow guide above |
## Known Issues (Pre-existing)
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 3d60ea8..09f0390 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -56,6 +56,7 @@ The image is published to: `ghcr.io/gerrri/labgrid-dashboard`
- Docker 20.10+ installed
- Running Labgrid coordinator (the dashboard connects to an existing coordinator)
- Network access from the dashboard container to the coordinator
+- Network access from the dashboard container to the exporter hosts used by serial command execution
## Quick Start
@@ -71,6 +72,7 @@ docker run -d \
-e CORS_ORIGINS=http://localhost \
-v ./commands.yaml:/app/commands.yaml:ro \
-v ./target_presets.json:/app/target_presets.json:ro \
+ -v ./exporter-ssh:/app/exporter-ssh:ro \
ghcr.io/gerrri/labgrid-dashboard:latest
```
@@ -159,11 +161,12 @@ Examples:
## Volume Mounts
-The dashboard requires two configuration files to be mounted:
+The dashboard requires two configuration files and the exporter SSH bundle tree to be mounted:
```bash
-v /path/to/commands.yaml:/app/commands.yaml:ro \
--v /path/to/target_presets.json:/app/target_presets.json:ro
+-v /path/to/target_presets.json:/app/target_presets.json:ro \
+-v /path/to/exporter-ssh:/app/exporter-ssh:ro
```
### commands.yaml
@@ -200,6 +203,53 @@ Defines hardware presets with associated commands. Example:
}
```
+### exporter-ssh
+
+This directory contains one subdirectory per exporter. Each exporter bundle provides the SSH material required for serial command execution to reach that exporter over SSH.
+
+Runtime input path:
+
+- `/app/exporter-ssh//exporter.yaml`
+- optional private key file in the same exporter directory, for example `/app/exporter-ssh//id_ed25519`
+
+Generated runtime material:
+
+- `~/.ssh/config` with an include for the managed exporter SSH snippet
+- `~/.ssh/labgrid-dashboard/config`
+- `~/.ssh/labgrid-dashboard/known_hosts`
+- `~/.ssh/labgrid-dashboard/keys/` when a private key is provided
+
+Supported auth modes:
+
+- private key
+- username/password
+
+Example bundle layout:
+
+```text
+exporter-ssh/
+ exporter-1/
+ exporter.yaml
+ id_ed25519
+ exporter-2/
+ exporter.yaml
+```
+
+Example `exporter.yaml`:
+
+```yaml
+alias: exporter-1
+host: 10.0.0.21
+port: 22
+ssh:
+ user: root
+ auth:
+ method: private_key
+ private_key_path: id_ed25519
+known_hosts:
+ - "exporter-1,10.0.0.21 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."
+```
+
## Production Considerations
### Security
@@ -208,7 +258,8 @@ Defines hardware presets with associated commands. Example:
2. **CORS Configuration**: Set `CORS_ORIGINS` to match your production domain(s)
3. **Network Isolation**: Use Docker networks to isolate the dashboard from other services
4. **Read-only Volumes**: Mount configuration files as read-only (`:ro` flag)
-5. **Non-root User**: The container runs as non-root user `appuser` (UID 1000)
+5. **Non-root User**: The container runs as non-root user `appuser` (UID 3000)
+6. **Exporter SSH Material**: Keep the exporter bundle tree read-only and provide trusted `known_hosts` entries for each exporter host
### Scaling
@@ -320,6 +371,7 @@ docker logs labgrid-dashboard
- Coordinator URL incorrect or unreachable
- Port 80 already in use (change port mapping: `-p 8080:80`)
- Configuration files not mounted correctly
+- Exporter SSH bundle tree not mounted or malformed
**Portainer-specific checks**:
- Verify logs of the `labgrid-dashboard` service/container (there is no separate backend container in the production image)
@@ -340,6 +392,26 @@ docker exec labgrid-dashboard curl -v ws://coordinator:20408/ws
docker exec labgrid-dashboard env | grep COORDINATOR
```
+### Serial commands fail with SSH errors
+
+Serial command execution still uses SSH to reach the exporter host. If you see connection errors, check the exporter bundle and host reachability.
+
+**Verify the bundle input exists**:
+```bash
+docker exec labgrid-dashboard ls -R /app/exporter-ssh
+```
+
+**Verify SSH runtime material**:
+```bash
+docker exec labgrid-dashboard ls -R /home/appuser/.ssh
+```
+
+**Common causes**:
+- Exporter host name or IP is wrong in the bundle
+- `known_hosts` does not match the exporter host key
+- The exporter is reachable with a private key, but the bundle is configured for username/password or vice versa
+- The dashboard container cannot reach the exporter host on port 22
+
### WebSocket connection fails
**Check CORS settings**:
diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md
index 8faa738..619d45f 100644
--- a/docs/RELEASE-CHECKLIST.md
+++ b/docs/RELEASE-CHECKLIST.md
@@ -27,6 +27,10 @@ This checklist ensures a smooth and reliable release process for the Labgrid Das
```bash
./scripts/test-production-image.sh
```
+- [ ] Exporter SSH bundles verified in staging:
+ - [ ] One exporter works with private key auth
+ - [ ] One exporter works with username/password auth
+ - [ ] Serial commands still reach exporters over SSH
- [ ] Manual testing completed:
- [ ] Dashboard loads correctly
- [ ] WebSocket connection works
@@ -93,6 +97,11 @@ This checklist ensures a smooth and reliable release process for the Labgrid Das
```bash
docker compose -f docker-compose.prod.yml up -d
```
+- [ ] Mount exporter SSH bundles and confirm generated SSH runtime files exist:
+ - `~/.ssh/config`
+ - `~/.ssh/labgrid-dashboard/config`
+ - `~/.ssh/labgrid-dashboard/known_hosts`
+ - `~/.ssh/labgrid-dashboard/keys/` when a private key is used
- [ ] Verify health endpoints:
```bash
curl http://localhost/health
diff --git a/quick-start.md b/quick-start.md
index 28a5229..92f4eac 100644
--- a/quick-start.md
+++ b/quick-start.md
@@ -12,7 +12,18 @@ docker compose up -d
docker compose --profile staging up -d --build
```
-This starts 3 simulated DUTs (Alpine Linux containers) with Labgrid exporters, providing a realistic test environment.
+This starts 4 simulated DUTs (Alpine Linux containers) with Labgrid exporters, providing a realistic test environment.
+
+The staging setup also exercises exporter SSH bundles so serial command execution can reach exporters over SSH:
+- `exporter-1` uses private key authentication
+- `exporter-4` uses username/password authentication
+- the bundle tree is mounted into the backend at `/app/exporter-ssh`
+
+The four staging targets are wired like this:
+- `exporter-1`: serial command execution
+- `exporter-2`: serial-first with SSH fallback
+- `exporter-3`: SSH using a DUT private key
+- `exporter-4`: SSH using DUT username/password
## Live Mode (Real Labgrid Coordinator)
@@ -22,6 +33,8 @@ export COORDINATOR_URL=ws://your-coordinator:20408/ws
docker compose up -d backend frontend
```
+For live mode, provide the exporter SSH bundle tree as well if you use serial command execution against real exporters.
+
## Stop All Services
```bash
From c3bbe4a7e11aaa5376cb982ad8112ae83a0c71c2 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:30:00 +0100
Subject: [PATCH 12/20] refactor(frontend): simplify command panel chrome
---
frontend/src/__tests__/TargetTable.test.tsx | 17 ++++--
.../components/CommandPanel/CommandPanel.css | 7 +--
.../components/CommandPanel/CommandPanel.tsx | 9 ++-
.../src/components/TargetTable/TargetRow.tsx | 56 +++----------------
.../components/TargetTable/TargetTable.css | 5 --
5 files changed, 26 insertions(+), 68 deletions(-)
diff --git a/frontend/src/__tests__/TargetTable.test.tsx b/frontend/src/__tests__/TargetTable.test.tsx
index f56c30d..ebc3195 100644
--- a/frontend/src/__tests__/TargetTable.test.tsx
+++ b/frontend/src/__tests__/TargetTable.test.tsx
@@ -180,17 +180,19 @@ describe("TargetTable", () => {
fireEvent.click(screen.getByRole("button", { name: /expand details/i }));
await waitFor(() => {
- expect(screen.getByText("Commands for test-dut-1")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Test Command" }),
+ ).toBeInTheDocument();
});
- expect(screen.getByText("Command transport: ssh")).toBeInTheDocument();
+ expect(screen.queryByText("Commands for test-dut-1")).not.toBeInTheDocument();
+ expect(screen.queryByText("Command transport: ssh")).not.toBeInTheDocument();
expect(
screen.getByTitle("Transport used for this execution"),
).toHaveTextContent("ssh");
- expect(screen.getByRole("button", { name: "Test Command" })).toBeInTheDocument();
});
- it("falls back to the target-level transport when no command output transport is available", async () => {
+ it("renders the command panel without a transport banner when no output exists", async () => {
render(
{
fireEvent.click(screen.getByRole("button", { name: /expand details/i }));
await waitFor(() => {
- expect(screen.getByText("Commands for test-dut-2")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Test Command" }),
+ ).toBeInTheDocument();
});
- expect(screen.getByText("Command transport: ssh")).toBeInTheDocument();
+ expect(screen.queryByText("Commands for test-dut-2")).not.toBeInTheDocument();
+ expect(screen.queryByText("Command transport: ssh")).not.toBeInTheDocument();
});
it("hides the command panel and shows an error for incapable targets", async () => {
diff --git a/frontend/src/components/CommandPanel/CommandPanel.css b/frontend/src/components/CommandPanel/CommandPanel.css
index 6a8d7d7..9f168fb 100644
--- a/frontend/src/components/CommandPanel/CommandPanel.css
+++ b/frontend/src/components/CommandPanel/CommandPanel.css
@@ -21,11 +21,8 @@
margin-bottom: 1rem;
}
-.command-panel-header h4 {
- margin: 0;
- font-size: 1rem;
- font-weight: 600;
- color: var(--color-text);
+.command-panel-header--actions {
+ justify-content: flex-end;
}
.btn-clear {
diff --git a/frontend/src/components/CommandPanel/CommandPanel.tsx b/frontend/src/components/CommandPanel/CommandPanel.tsx
index 1abd2e3..6747e54 100644
--- a/frontend/src/components/CommandPanel/CommandPanel.tsx
+++ b/frontend/src/components/CommandPanel/CommandPanel.tsx
@@ -139,9 +139,8 @@ export function CommandPanel({
return (
-
-
Commands for {targetName}
- {outputs.length > 0 && (
+ {outputs.length > 0 && (
+
Clear
- )}
-
+
+ )}
{error && (
diff --git a/frontend/src/components/TargetTable/TargetRow.tsx b/frontend/src/components/TargetTable/TargetRow.tsx
index 21d0420..4ab2f87 100644
--- a/frontend/src/components/TargetTable/TargetRow.tsx
+++ b/frontend/src/components/TargetTable/TargetRow.tsx
@@ -4,8 +4,6 @@ import type {
Target,
CommandOutput,
ScheduledCommand,
- ScheduledCommandOutput,
- ExecutionTransport,
} from "../../types";
import { StatusBadge } from "./StatusBadge";
import { CommandPanel } from "../CommandPanel";
@@ -122,34 +120,6 @@ export function TargetRow({
onCommandOutputsChange?.(target.name, outputs);
};
- const getTransportLabel = () => {
- const getOutputTransport = (
- output:
- | CommandOutput
- | ScheduledCommandOutput
- | null
- | undefined,
- ): ExecutionTransport | null => output?.execution_transport ?? null;
-
- const latestManualOutput =
- (commandOutputs && commandOutputs.length > 0
- ? commandOutputs[0]
- : target.last_command_outputs[0]) ?? null;
-
- const latestScheduledOutput =
- Object.values(target.scheduled_outputs ?? {}).sort(
- (left, right) =>
- Date.parse(right.timestamp) - Date.parse(left.timestamp),
- )[0] ?? null;
-
- return (
- getOutputTransport(latestManualOutput) ??
- getOutputTransport(latestScheduledOutput) ??
- target.command_transport ??
- null
- );
- };
-
const renderIpAddress = () => {
if (!target.ip_address) {
return
- ;
@@ -311,7 +281,6 @@ export function TargetRow({
(target.status === "offline"
? "Commands unavailable - target is offline"
: "Commands unavailable for this target");
- const commandTransport = getTransportLabel();
return (
<>
@@ -357,22 +326,15 @@ export function TargetRow({
/* Command Panel Section */
{canExecuteCommands ? (
- <>
- {commandTransport && (
-
- Command transport: {commandTransport}
-
- )}
-
- >
+
) : (
diff --git a/frontend/src/components/TargetTable/TargetTable.css b/frontend/src/components/TargetTable/TargetTable.css
index 3e4fb1d..db1451d 100644
--- a/frontend/src/components/TargetTable/TargetTable.css
+++ b/frontend/src/components/TargetTable/TargetTable.css
@@ -181,11 +181,6 @@
margin-bottom: 0;
}
-.command-transport-indicator {
- margin-bottom: 0.75rem;
- font-size: 0.875rem;
-}
-
.commands-unavailable {
padding: 1rem;
border: 1px solid var(--color-border);
From 7b4dd9196c2bc546fc92faa8cd35c6730ea79a38 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:39:45 +0100
Subject: [PATCH 13/20] refactor(frontend): move clear action into terminal
header
---
.../components/CommandPanel/CommandPanel.css | 21 +++++-----
.../components/CommandPanel/CommandPanel.tsx | 14 +------
.../components/CommandPanel/OutputViewer.tsx | 39 +++++++++++++++----
3 files changed, 42 insertions(+), 32 deletions(-)
diff --git a/frontend/src/components/CommandPanel/CommandPanel.css b/frontend/src/components/CommandPanel/CommandPanel.css
index 9f168fb..040c171 100644
--- a/frontend/src/components/CommandPanel/CommandPanel.css
+++ b/frontend/src/components/CommandPanel/CommandPanel.css
@@ -14,17 +14,6 @@
min-height: 150px;
}
-.command-panel-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
-}
-
-.command-panel-header--actions {
- justify-content: flex-end;
-}
-
.btn-clear {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
@@ -40,6 +29,16 @@
color: var(--color-text);
}
+.btn-clear-terminal {
+ border-color: #4b5563;
+ color: #d1d5db;
+}
+
+.btn-clear-terminal:hover {
+ background-color: #374151;
+ color: #f9fafb;
+}
+
/* Loading Spinner */
.loading-spinner {
display: flex;
diff --git a/frontend/src/components/CommandPanel/CommandPanel.tsx b/frontend/src/components/CommandPanel/CommandPanel.tsx
index 6747e54..be81379 100644
--- a/frontend/src/components/CommandPanel/CommandPanel.tsx
+++ b/frontend/src/components/CommandPanel/CommandPanel.tsx
@@ -139,18 +139,6 @@ export function CommandPanel({
return (
- {outputs.length > 0 && (
-
-
- Clear
-
-
- )}
-
{error && (
⚠
@@ -177,7 +165,7 @@ export function CommandPanel({
-
+
{onSettingsClick && (
diff --git a/frontend/src/components/CommandPanel/OutputViewer.tsx b/frontend/src/components/CommandPanel/OutputViewer.tsx
index 24b217f..8864e01 100644
--- a/frontend/src/components/CommandPanel/OutputViewer.tsx
+++ b/frontend/src/components/CommandPanel/OutputViewer.tsx
@@ -1,18 +1,25 @@
-import type { CommandOutput, ExecutionTransport } from '../../types';
+import type { CommandOutput, ExecutionTransport } from "../../types";
interface OutputViewerProps {
outputs: CommandOutput[];
maxHeight?: string;
+ onClear?: () => void;
}
/**
* Terminal-like output viewer for command results
*/
-export function OutputViewer({ outputs, maxHeight = '300px' }: OutputViewerProps) {
+export function OutputViewer({
+ outputs,
+ maxHeight = "300px",
+ onClear,
+}: OutputViewerProps) {
if (outputs.length === 0) {
return (
-
No command output yet. Execute a command to see results.
+
+ No command output yet. Execute a command to see results.
+
);
}
@@ -29,23 +36,39 @@ export function OutputViewer({ outputs, maxHeight = '300px' }: OutputViewerProps
{outputs.map((output, index) => {
const transport = getTransportLabel(output);
+ const showClearButton = index === 0 && onClear !== undefined;
return (
-
+
$ {output.command}
{transport && (
-
+
{transport}
)}
- {output.exit_code === 0 ? '✓' : '✗'} {output.exit_code}
+ {output.exit_code === 0 ? "✓" : "✗"} {output.exit_code}
+ {showClearButton && (
+
+ Clear
+
+ )}
{formatTimestamp(output.timestamp)}
From 3b142cde483d9e3600cb9e8fac5defbe143434e2 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 16:00:43 +0100
Subject: [PATCH 14/20] refactor(frontend): refine preset headers and table
alignment
---
frontend/src/__tests__/TargetTable.test.tsx | 33 +++++
.../components/TargetTable/TargetTable.css | 120 ++++++++++++++++--
.../components/TargetTable/TargetTable.tsx | 42 ++++--
3 files changed, 174 insertions(+), 21 deletions(-)
diff --git a/frontend/src/__tests__/TargetTable.test.tsx b/frontend/src/__tests__/TargetTable.test.tsx
index ebc3195..6dfcdff 100644
--- a/frontend/src/__tests__/TargetTable.test.tsx
+++ b/frontend/src/__tests__/TargetTable.test.tsx
@@ -114,6 +114,39 @@ describe("TargetTable", () => {
expect(screen.getByText("test-dut-3")).toBeInTheDocument();
});
+ it("renders the preset description as an info symbol in the preset header", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("TAC")).toBeInTheDocument();
+ const trigger = screen.getByLabelText(
+ "Preset description: Serial-first preset for labgrid exporters",
+ );
+
+ expect(trigger).toHaveTextContent("i");
+ expect(trigger).toHaveAttribute(
+ "data-tooltip",
+ "Serial-first preset for labgrid exporters",
+ );
+ expect(
+ screen.queryByText("Serial-first preset for labgrid exporters"),
+ ).not.toBeInTheDocument();
+ });
+
it("renders status badges", () => {
render(
{
- const count = sortedTargets.length;
- return `${count} Target${count !== 1 ? "s" : ""}`;
- }, [sortedTargets.length]);
+ const tableStyle = useMemo(
+ () =>
+ ({
+ "--scheduled-column-count": effectiveScheduledCommands.length,
+ }) as CSSProperties,
+ [effectiveScheduledCommands.length],
+ );
// Show empty state only when not loading and no targets
if (!loading && sortedTargets.length === 0) {
@@ -97,12 +99,18 @@ export function TargetTable({
{showPresetHeader && preset && (
- {preset.name}{" "}
- ({targetCountText})
+ {preset.name}
+ {preset.description && (
+
+ i
+
+ )}
- {preset.description && (
-
{preset.description}
- )}
)}
{/* Show loading overlay instead of replacing the entire table */}
@@ -114,7 +122,17 @@ export function TargetTable({
)}
{sortedTargets.length > 0 && (
<>
-
+
+
+
+
+
+
+ {effectiveScheduledCommands.map((cmd) => (
+
+ ))}
+
+
Name
From a2138214e1776eb7b42abd460796690152046ba9 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 16:21:09 +0100
Subject: [PATCH 15/20] chore(release): bump version to 0.1.5
---
backend/app/main.py | 2 +-
frontend/package-lock.json | 4 ++--
frontend/package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 7803572..2447e29 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -299,7 +299,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title="Labgrid Dashboard API",
description="REST API for Labgrid Dashboard - Monitor and interact with DUTs",
- version="0.1.4",
+ version="0.1.5",
lifespan=lifespan,
)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4d6eb47..1e54241 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "labgrid-dashboard-frontend",
- "version": "0.1.4",
+ "version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "labgrid-dashboard-frontend",
- "version": "0.1.4",
+ "version": "0.1.5",
"dependencies": {
"axios": "^1.7.0",
"react": "^19.2.0",
diff --git a/frontend/package.json b/frontend/package.json
index 4eb7bf7..65bc7c1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "labgrid-dashboard-frontend",
"private": true,
- "version": "0.1.4",
+ "version": "0.1.5",
"type": "module",
"scripts": {
"dev": "vite",
From fc7df18299f367c79bac8117eb95c9575c4db93d Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 17:10:41 +0100
Subject: [PATCH 16/20] fix(backend): address review findings for scheduling
and resources
---
backend/app/services/exporter_ssh_runtime.py | 4 +-
backend/app/services/labgrid_client.py | 95 +++++++++-
backend/app/services/scheduler_service.py | 188 ++++++++++++++-----
backend/tests/test_exporter_ssh_runtime.py | 12 +-
backend/tests/test_labgrid_client.py | 59 ++++++
backend/tests/test_scheduler_service.py | 83 +++++++-
6 files changed, 386 insertions(+), 55 deletions(-)
diff --git a/backend/app/services/exporter_ssh_runtime.py b/backend/app/services/exporter_ssh_runtime.py
index 0cc1da5..be49797 100644
--- a/backend/app/services/exporter_ssh_runtime.py
+++ b/backend/app/services/exporter_ssh_runtime.py
@@ -542,7 +542,9 @@ def main():
raise SystemExit(255)
rewritten_args = rewrite_password_args(sys.argv[1:])
- os.execv(sshpass, [sshpass, "-p", password, real_ssh, *rewritten_args])
+ env = os.environ.copy()
+ env["SSHPASS"] = password
+ os.execvpe(sshpass, [sshpass, "-e", real_ssh, *rewritten_args], env)
if __name__ == "__main__":
diff --git a/backend/app/services/labgrid_client.py b/backend/app/services/labgrid_client.py
index dbb5c2f..89e7e99 100644
--- a/backend/app/services/labgrid_client.py
+++ b/backend/app/services/labgrid_client.py
@@ -634,11 +634,42 @@ def _get_place_resource_entries(
"""Get all resource entries that belong to a coordinator place."""
place_data = self._places_cache.get(place_name, {})
exporters = self._get_place_exporters(place_name, place_data)
+ matched_resources = self._get_place_matched_resources(place_data)
entries: List[Tuple[str, str, Dict[str, Any]]] = []
+ seen_entries: set[Tuple[str, str]] = set()
+ if matched_resources:
+ for exporter_name, resource_key in matched_resources:
+ exporter_resources = self._resources_cache.get(exporter_name, {})
+ if resource_key is None:
+ for res_type, res_data in exporter_resources.items():
+ entry_key = (exporter_name, res_type)
+ if entry_key in seen_entries:
+ continue
+ seen_entries.add(entry_key)
+ entries.append((exporter_name, res_type, res_data))
+ continue
+
+ res_data = exporter_resources.get(resource_key)
+ if res_data is None:
+ continue
+
+ entry_key = (exporter_name, resource_key)
+ if entry_key in seen_entries:
+ continue
+ seen_entries.add(entry_key)
+ entries.append((exporter_name, resource_key, res_data))
+
+ if entries:
+ return entries
+
for exporter_name in exporters:
exporter_resources = self._resources_cache.get(exporter_name, {})
for res_type, res_data in exporter_resources.items():
+ entry_key = (exporter_name, res_type)
+ if entry_key in seen_entries:
+ continue
+ seen_entries.add(entry_key)
entries.append((exporter_name, res_type, res_data))
return entries
@@ -668,10 +699,72 @@ def _get_place_exporters(
return exporters
+ def _get_place_matched_resources(
+ self, place_data: Dict[str, Any]
+ ) -> List[Tuple[str, Optional[str]]]:
+ """Resolve exporter/resource pairs from coordinator match entries."""
+ matches: List[Tuple[str, Optional[str]]] = []
+ for match in place_data.get("matches", []):
+ resolved = self._extract_match_resource(match)
+ if resolved and resolved not in matches:
+ matches.append(resolved)
+ return matches
+
+ def _extract_match_resource(
+ self, match: Any
+ ) -> Optional[Tuple[str, Optional[str]]]:
+ """Best-effort exporter/resource extraction from a labgrid match entry."""
+ if isinstance(match, str):
+ parts = [part for part in match.split("/") if part]
+ if not parts:
+ return None
+ exporter_name = parts[0]
+ resource_key = "/".join(parts[1:]) or None
+ return (exporter_name, resource_key)
+
+ exporter_name = self._extract_match_exporter(match)
+ if not exporter_name:
+ return None
+
+ resource_key = self._extract_match_resource_key(match, exporter_name)
+ return (exporter_name, resource_key)
+
+ def _extract_match_resource_key(
+ self, match: Any, exporter_name: str
+ ) -> Optional[str]:
+ """Extract the resource portion from a non-string match entry."""
+ raw_resource: Optional[str] = None
+
+ if isinstance(match, dict):
+ for key in ("resource", "resource_key", "path", "match"):
+ value = match.get(key)
+ if isinstance(value, str) and value:
+ raw_resource = value
+ break
+ elif isinstance(match, (list, tuple)):
+ for value in match:
+ if isinstance(value, str) and value.startswith(f"{exporter_name}/"):
+ raw_resource = value
+ break
+ else:
+ for attr in ("resource", "resource_key", "path", "match"):
+ value = getattr(match, attr, None)
+ if isinstance(value, str) and value:
+ raw_resource = value
+ break
+
+ if not raw_resource:
+ return None
+
+ if raw_resource.startswith(f"{exporter_name}/"):
+ raw_resource = raw_resource[len(exporter_name) + 1 :]
+
+ return raw_resource.strip("/") or None
+
def _extract_match_exporter(self, match: Any) -> Optional[str]:
"""Best-effort exporter extraction from a labgrid place match entry."""
if isinstance(match, str):
- return match
+ return match.split("/", 1)[0] if match else None
if isinstance(match, dict):
for key in ("exporter", "name"):
diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py
index 1c8d520..f015284 100644
--- a/backend/app/services/scheduler_service.py
+++ b/backend/app/services/scheduler_service.py
@@ -10,9 +10,10 @@
import asyncio
import logging
+from dataclasses import dataclass
from datetime import datetime, timezone
from copy import deepcopy
-from typing import Callable, Dict, List, Optional, Set
+from typing import Callable, Dict, List, Optional, Set, Union
from app.models.target import ScheduledCommand, ScheduledCommandOutput
@@ -22,6 +23,15 @@
SCHEDULER_ERROR_BACKOFF_MAX = 60
+@dataclass(frozen=True)
+class _ScheduledCommandRegistration:
+ """Internal scheduler registration for a preset-scoped command."""
+
+ key: str
+ preset_id: str
+ command: ScheduledCommand
+
+
class SchedulerService:
"""Service for executing scheduled commands periodically with preset support."""
@@ -29,11 +39,12 @@ def __init__(self):
"""Initialize the scheduler service."""
# All unique scheduled commands from all presets (for display in UI)
self._all_commands: List[ScheduledCommand] = []
+ self._command_registrations: List[_ScheduledCommandRegistration] = []
# Scheduled commands per preset: preset_id -> List[ScheduledCommand]
self._preset_commands: Dict[str, List[ScheduledCommand]] = {}
- # Latest outputs: command_name -> target_name -> output
+ # Latest outputs keyed by internal registration key -> target_name -> output
self._outputs: Dict[str, Dict[str, ScheduledCommandOutput]] = {}
- # Running tasks for each command
+ # Running tasks keyed by internal registration key
self._tasks: Dict[str, asyncio.Task] = {}
# Callback for executing commands on targets
self._execute_callback: Optional[Callable] = None
@@ -58,12 +69,20 @@ def set_commands(self, commands: List[ScheduledCommand]) -> None:
commands: List of scheduled commands to execute.
"""
self._all_commands = commands
+ self._command_registrations = [
+ _ScheduledCommandRegistration(
+ key=cmd.name,
+ preset_id="basic",
+ command=cmd,
+ )
+ for cmd in commands
+ ]
# Treat all commands as belonging to a "basic" preset
self._preset_commands = {"basic": commands}
# Initialize output storage for each command
- for cmd in commands:
- if cmd.name not in self._outputs:
- self._outputs[cmd.name] = {}
+ self._outputs = {
+ registration.key: {} for registration in self._command_registrations
+ }
logger.info(f"Configured {len(commands)} scheduled commands (legacy mode)")
def set_preset_commands(
@@ -76,16 +95,27 @@ def set_preset_commands(
"""
self._preset_commands = preset_commands
- # Build list of all unique commands for backwards compatibility
+ # Build registrations for every preset-scoped command while preserving
+ # a unique display-name list for the existing UI/API surface.
seen_names: Set[str] = set()
self._all_commands = []
- for commands in preset_commands.values():
- for cmd in commands:
+ self._command_registrations = []
+ for preset_id, commands in preset_commands.items():
+ for index, cmd in enumerate(commands):
+ self._command_registrations.append(
+ _ScheduledCommandRegistration(
+ key=self._build_registration_key(preset_id, cmd, index),
+ preset_id=preset_id,
+ command=cmd,
+ )
+ )
if cmd.name not in seen_names:
seen_names.add(cmd.name)
self._all_commands.append(cmd)
- if cmd.name not in self._outputs:
- self._outputs[cmd.name] = {}
+ self._outputs = {
+ registration.key: self._outputs.get(registration.key, {})
+ for registration in self._command_registrations
+ }
total_commands = sum(len(cmds) for cmds in preset_commands.values())
logger.info(
@@ -132,8 +162,8 @@ async def start(self) -> None:
logger.info("Scheduler service starting...")
# Start a task for each unique scheduled command
- for cmd in self._all_commands:
- await self._start_command_task(cmd)
+ for registration in self._command_registrations:
+ await self._start_command_task(registration)
logger.info(f"Scheduler service started with {len(self._tasks)} tasks")
@@ -183,33 +213,82 @@ def get_outputs_for_target(
Dictionary of command_name -> output for the target.
"""
result = {}
- for cmd_name, targets in self._outputs.items():
+ active_preset_id = None
+ if self._get_target_preset_callback:
+ active_preset_id = self._get_target_preset_callback(target_name)
+
+ for registration in self._command_registrations:
+ if (
+ active_preset_id is not None
+ and registration.preset_id != active_preset_id
+ ):
+ continue
+
+ targets = self._outputs.get(registration.key, {})
if target_name in targets:
- result[cmd_name] = targets[target_name]
+ result[registration.command.name] = targets[target_name]
return result
def get_all_outputs(self) -> Dict[str, Dict[str, ScheduledCommandOutput]]:
"""Get all outputs for all commands and targets.
Returns:
- Nested dictionary: command_name -> target_name -> output
+ Nested dictionary keyed by internal scheduler command key.
"""
return deepcopy(self._outputs)
- async def _start_command_task(self, cmd: ScheduledCommand) -> None:
+ def _build_registration_key(
+ self,
+ preset_id: str,
+ cmd: ScheduledCommand,
+ index: int,
+ ) -> str:
+ """Build a stable internal key for a preset-scoped scheduled command."""
+ return f"{preset_id}:{index}:{cmd.name}"
+
+ def _resolve_registration(
+ self,
+ command: Union[ScheduledCommand, _ScheduledCommandRegistration],
+ ) -> _ScheduledCommandRegistration:
+ """Resolve an internal registration for a scheduled command input."""
+ if isinstance(command, _ScheduledCommandRegistration):
+ return command
+
+ for registration in self._command_registrations:
+ if registration.command is command:
+ return registration
+
+ return _ScheduledCommandRegistration(
+ key=command.name,
+ preset_id="basic",
+ command=command,
+ )
+
+ async def _start_command_task(
+ self,
+ command: Union[ScheduledCommand, _ScheduledCommandRegistration],
+ ) -> None:
"""Start the periodic execution task for a command."""
- if cmd.name in self._tasks:
+ registration = self._resolve_registration(command)
+ if registration.key in self._tasks:
return # Already running
- task = asyncio.create_task(self._run_command_loop(cmd))
- self._tasks[cmd.name] = task
+ task = asyncio.create_task(self._run_command_loop(registration))
+ self._tasks[registration.key] = task
logger.info(
- f"Started scheduler task for '{cmd.name}' (interval: {cmd.interval_seconds}s)"
+ "Started scheduler task for '%s' in preset '%s' (interval: %ss)",
+ registration.command.name,
+ registration.preset_id,
+ registration.command.interval_seconds,
)
- async def _run_command_loop(self, cmd: ScheduledCommand) -> None:
+ async def _run_command_loop(
+ self,
+ command: Union[ScheduledCommand, _ScheduledCommandRegistration],
+ ) -> None:
"""Run the periodic execution loop for a command."""
- logger.debug(f"Command loop started for '{cmd.name}'")
+ registration = self._resolve_registration(command)
+ logger.debug("Command loop started for '%s'", registration.command.name)
retry_delay = SCHEDULER_ERROR_BACKOFF_INITIAL
run_immediately = True
@@ -218,19 +297,25 @@ async def _run_command_loop(self, cmd: ScheduledCommand) -> None:
if run_immediately:
run_immediately = False
else:
- await asyncio.sleep(cmd.interval_seconds)
+ await asyncio.sleep(registration.command.interval_seconds)
if not self._running:
break
- await self._execute_on_targets_with_preset(cmd)
+ await self._execute_on_targets_with_preset(registration)
retry_delay = SCHEDULER_ERROR_BACKOFF_INITIAL
except asyncio.CancelledError:
- logger.debug(f"Command loop cancelled for '{cmd.name}'")
+ logger.debug(
+ "Command loop cancelled for '%s'", registration.command.name
+ )
break
except Exception as e:
- logger.error(f"Error in command loop for '{cmd.name}': {e}")
+ logger.error(
+ "Error in command loop for '%s': %s",
+ registration.command.name,
+ e,
+ )
await asyncio.sleep(retry_delay)
retry_delay = self._get_next_retry_delay(retry_delay)
@@ -238,7 +323,10 @@ def _get_next_retry_delay(self, current_delay: int) -> int:
"""Compute the next retry delay with an exponential backoff cap."""
return min(current_delay * 2, SCHEDULER_ERROR_BACKOFF_MAX)
- async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
+ async def _execute_on_targets_with_preset(
+ self,
+ command: Union[ScheduledCommand, _ScheduledCommandRegistration],
+ ) -> None:
"""Execute a command on targets that have this command in their preset.
Scheduled commands run on ALL targets except offline ones.
@@ -248,8 +336,10 @@ async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
commands try to execute on the same target simultaneously.
Args:
- cmd: The scheduled command to execute.
+ command: The scheduled command or internal registration to execute.
"""
+ registration = self._resolve_registration(command)
+ cmd = registration.command
if not self._execute_callback or not self._get_targets_callback:
logger.warning("Callbacks not configured, skipping execution")
return
@@ -272,7 +362,7 @@ async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
continue
# Check if this command applies to this target's preset
- if not self._should_execute_on_target(cmd, target.name):
+ if not self._should_execute_on_target(registration, target.name):
logger.info(
f"Skipping '{cmd.name}' on '{target.name}': command not in target's preset"
)
@@ -317,9 +407,9 @@ async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
execution_transport=execution_transport,
)
- if cmd.name not in self._outputs:
- self._outputs[cmd.name] = {}
- self._outputs[cmd.name][target.name] = scheduled_output
+ if registration.key not in self._outputs:
+ self._outputs[registration.key] = {}
+ self._outputs[registration.key][target.name] = scheduled_output
# Notify listeners (e.g., WebSocket clients)
if self._notify_callback:
@@ -348,31 +438,27 @@ async def _execute_on_targets_with_preset(self, cmd: ScheduledCommand) -> None:
)
def _should_execute_on_target(
- self, cmd: ScheduledCommand, target_name: str
+ self,
+ command: Union[ScheduledCommand, _ScheduledCommandRegistration],
+ target_name: str,
) -> bool:
"""Check if a command should be executed on a target based on its preset.
Args:
- cmd: The scheduled command.
+ command: The scheduled command or internal registration.
target_name: The target name.
Returns:
True if the command should be executed on the target.
"""
+ registration = self._resolve_registration(command)
# If no preset callback is configured, execute on all targets (legacy mode)
if not self._get_target_preset_callback:
return True
# Get the target's preset
preset_id = self._get_target_preset_callback(target_name)
-
- # Check if the command exists in this preset's scheduled commands
- preset_commands = self._preset_commands.get(preset_id, [])
- for preset_cmd in preset_commands:
- if preset_cmd.name == cmd.name:
- return True
-
- return False
+ return preset_id == registration.preset_id
async def execute_now(self, command_name: str) -> bool:
"""Manually trigger immediate execution of a scheduled command.
@@ -383,11 +469,17 @@ async def execute_now(self, command_name: str) -> bool:
Returns:
True if execution was triggered, False if command not found.
"""
- for cmd in self._all_commands:
- if cmd.name == command_name:
- await self._execute_on_targets_with_preset(cmd)
- return True
- return False
+ matched = [
+ registration
+ for registration in self._command_registrations
+ if registration.command.name == command_name
+ ]
+ if not matched:
+ return False
+
+ for registration in matched:
+ await self._execute_on_targets_with_preset(registration)
+ return True
# Legacy method - kept for backwards compatibility
async def _execute_on_all_targets(self, cmd: ScheduledCommand) -> None:
diff --git a/backend/tests/test_exporter_ssh_runtime.py b/backend/tests/test_exporter_ssh_runtime.py
index f046492..ed1461a 100644
--- a/backend/tests/test_exporter_ssh_runtime.py
+++ b/backend/tests/test_exporter_ssh_runtime.py
@@ -125,7 +125,10 @@ def test_exporter_ssh_wrapper_uses_sshpass_for_password_exporters(
_write_executable(
fake_bin / "sshpass",
- "#!/bin/sh\nprintf '%s\\n' \"$@\"\n",
+ "#!/bin/sh\n"
+ "test -n \"$SSHPASS\" || exit 2\n"
+ "printf 'SSHPASS_SET\\n'\n"
+ "printf '%s\\n' \"$@\"\n",
)
_write_executable(
fake_bin / "ssh-real",
@@ -158,8 +161,11 @@ def test_exporter_ssh_wrapper_uses_sshpass_for_password_exporters(
)
output = result.stdout
- assert "-p" in output
- assert "s3cr3t" in output
+ output_lines = output.splitlines()
+ assert "SSHPASS_SET" in output
+ assert "-e" in output_lines
+ assert "-p" not in output_lines
+ assert all("s3cr3t" not in line for line in output_lines)
assert "PasswordAuthentication=no" not in output
assert "PasswordAuthentication=yes" in output
assert "exporter-password" in output
diff --git a/backend/tests/test_labgrid_client.py b/backend/tests/test_labgrid_client.py
index b3c1c81..8b33431 100644
--- a/backend/tests/test_labgrid_client.py
+++ b/backend/tests/test_labgrid_client.py
@@ -367,6 +367,65 @@ async def test_get_places_uses_place_matches_for_resources(
assert places[0].web_url == "http://example.invalid"
assert places[0].resources[0].type == "NetworkSerialPort"
+ def test_get_place_resource_entries_only_returns_matched_resources(
+ self, connected_client: LabgridClient
+ ):
+ """Only resources matched to the place should be exposed for transport resolution."""
+ connected_client._resources_cache = {
+ "exporter-shared": {
+ "console/NetworkSerialPort": {
+ "cls": "NetworkSerialPort",
+ "params": {"host": "192.168.1.100", "port": 5000},
+ "acquired": None,
+ "avail": True,
+ },
+ "ssh/NetworkService": {
+ "cls": "NetworkService",
+ "params": {"address": "192.168.1.100"},
+ "acquired": None,
+ "avail": True,
+ },
+ }
+ }
+ connected_client._places_cache = {
+ "place-serial": {
+ "name": "place-serial",
+ "acquired": None,
+ "comment": "",
+ "tags": {},
+ "matches": ["exporter-shared/console/NetworkSerialPort"],
+ },
+ "place-ssh": {
+ "name": "place-ssh",
+ "acquired": None,
+ "comment": "",
+ "tags": {},
+ "matches": ["exporter-shared/ssh/NetworkService"],
+ },
+ }
+
+ serial_entries = connected_client.get_place_resource_entries("place-serial")
+ ssh_entries = connected_client.get_place_resource_entries("place-ssh")
+
+ assert serial_entries == [
+ (
+ "exporter-shared",
+ "console/NetworkSerialPort",
+ connected_client._resources_cache["exporter-shared"][
+ "console/NetworkSerialPort"
+ ],
+ )
+ ]
+ assert ssh_entries == [
+ (
+ "exporter-shared",
+ "ssh/NetworkService",
+ connected_client._resources_cache["exporter-shared"][
+ "ssh/NetworkService"
+ ],
+ )
+ ]
+
@pytest.mark.asyncio
async def test_refresh_cache_keeps_empty_params_resources_online(
self, connected_client: LabgridClient
diff --git a/backend/tests/test_scheduler_service.py b/backend/tests/test_scheduler_service.py
index f93e73e..f1028bb 100644
--- a/backend/tests/test_scheduler_service.py
+++ b/backend/tests/test_scheduler_service.py
@@ -104,6 +104,7 @@ def test_init(self, scheduler):
# Arrange & Act - created by fixture
# Assert
assert scheduler._all_commands == []
+ assert scheduler._command_registrations == []
assert scheduler._preset_commands == {}
assert scheduler._outputs == {}
assert scheduler._tasks == {}
@@ -121,6 +122,7 @@ def test_set_commands_legacy(self, scheduler, sample_commands):
# Assert
assert scheduler._all_commands == sample_commands
+ assert len(scheduler._command_registrations) == 2
assert scheduler._preset_commands == {"basic": sample_commands}
assert "uptime" in scheduler._outputs
assert "free" in scheduler._outputs
@@ -158,8 +160,10 @@ def test_set_preset_commands(self, scheduler):
# Assert
assert scheduler._preset_commands == preset_commands
assert len(scheduler._all_commands) == 2 # uptime and sensors (unique)
- assert "uptime" in scheduler._outputs
- assert "sensors" in scheduler._outputs
+ assert len(scheduler._command_registrations) == 3
+ assert "basic:0:uptime" in scheduler._outputs
+ assert "advanced:0:uptime" in scheduler._outputs
+ assert "advanced:1:sensors" in scheduler._outputs
def test_set_callbacks(self, scheduler):
"""Test setting callbacks."""
@@ -227,6 +231,22 @@ def test_get_commands_for_preset(self, scheduler):
def test_get_outputs_for_target(self, scheduler):
"""Test getting outputs for a specific target."""
# Arrange
+ scheduler.set_commands(
+ [
+ ScheduledCommand(
+ name="uptime",
+ command="uptime",
+ interval_seconds=5,
+ description="",
+ ),
+ ScheduledCommand(
+ name="free",
+ command="free -h",
+ interval_seconds=10,
+ description="",
+ ),
+ ]
+ )
scheduler._outputs = {
"uptime": {
"dut-1": ScheduledCommandOutput(
@@ -481,6 +501,65 @@ async def test_execute_on_targets_with_preset_filtering(
# dut-3 is offline anyway
assert execute_callback.call_count == 2
+ @pytest.mark.asyncio
+ async def test_execute_on_targets_supports_duplicate_command_names_across_presets(
+ self, scheduler
+ ):
+ """Different presets may reuse the same display name without collisions."""
+ basic_cmd = ScheduledCommand(
+ name="Version",
+ command="cat /etc/version",
+ interval_seconds=5,
+ description="Basic version",
+ )
+ advanced_cmd = ScheduledCommand(
+ name="Version",
+ command="cat /etc/os-release",
+ interval_seconds=30,
+ description="Advanced version",
+ )
+ scheduler.set_preset_commands(
+ {"basic": [basic_cmd], "advanced": [advanced_cmd]}
+ )
+
+ targets = [
+ Target(name="dut-basic", status="available", acquired_by=None, resources=[]),
+ Target(
+ name="dut-advanced",
+ status="available",
+ acquired_by=None,
+ resources=[],
+ ),
+ ]
+ execute_callback = AsyncMock(
+ side_effect=lambda target_name, command: (f"{target_name}:{command}", 0)
+ )
+ scheduler.set_execute_callback(execute_callback)
+ scheduler.set_get_targets_callback(AsyncMock(return_value=targets))
+ scheduler.set_get_target_preset_callback(
+ lambda name: "basic" if name == "dut-basic" else "advanced"
+ )
+
+ basic_registration, advanced_registration = scheduler._command_registrations
+
+ await scheduler._execute_on_targets_with_preset(basic_registration)
+ await scheduler._execute_on_targets_with_preset(advanced_registration)
+
+ assert execute_callback.await_args_list[0].args == (
+ "dut-basic",
+ "cat /etc/version",
+ )
+ assert execute_callback.await_args_list[1].args == (
+ "dut-advanced",
+ "cat /etc/os-release",
+ )
+ assert scheduler.get_outputs_for_target("dut-basic")["Version"].output == (
+ "dut-basic:cat /etc/version"
+ )
+ assert scheduler.get_outputs_for_target("dut-advanced")["Version"].output == (
+ "dut-advanced:cat /etc/os-release"
+ )
+
@pytest.mark.asyncio
async def test_execute_on_targets_handles_execution_error(
self, scheduler, sample_command, sample_target
From 241b63ae81ad7d854a42ccc46bb4376caa0872d3 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 17:12:27 +0100
Subject: [PATCH 17/20] chore(release): bump version to 0.1.6
---
backend/app/main.py | 2 +-
frontend/package-lock.json | 4 ++--
frontend/package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 2447e29..1e18d4f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -299,7 +299,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title="Labgrid Dashboard API",
description="REST API for Labgrid Dashboard - Monitor and interact with DUTs",
- version="0.1.5",
+ version="0.1.6",
lifespan=lifespan,
)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1e54241..5a5a902 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "labgrid-dashboard-frontend",
- "version": "0.1.5",
+ "version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "labgrid-dashboard-frontend",
- "version": "0.1.5",
+ "version": "0.1.6",
"dependencies": {
"axios": "^1.7.0",
"react": "^19.2.0",
diff --git a/frontend/package.json b/frontend/package.json
index 65bc7c1..13b8ca6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "labgrid-dashboard-frontend",
"private": true,
- "version": "0.1.5",
+ "version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
From 2bf2406aaa3ead60d0a97ed0a1e24a3a993fdab4 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:19:14 +0100
Subject: [PATCH 18/20] fix(backend): validate scheduled commands and resolve
wildcard matches
---
README.md | 4 +
backend/app/services/command_service.py | 26 +++
backend/app/services/labgrid_client.py | 157 ++++++++++++----
backend/commands.yaml | 20 +++
backend/tests/test_command_service_presets.py | 70 ++++++++
backend/tests/test_labgrid_client.py | 167 ++++++++++++++++++
6 files changed, 412 insertions(+), 32 deletions(-)
diff --git a/README.md b/README.md
index 84816fd..1de1e52 100644
--- a/README.md
+++ b/README.md
@@ -409,6 +409,10 @@ presets:
interval_seconds: 30
```
+Scheduled command names must be unique within each preset. The scheduler uses the
+display name as the column key in the UI, so duplicate names inside the same
+preset are rejected during configuration loading.
+
**Preset Assignment:**
- Targets are assigned to presets via the Settings icon (⚙️) in the expanded target view
- Assignments are stored in `target_presets.json`
diff --git a/backend/app/services/command_service.py b/backend/app/services/command_service.py
index d625dbd..96392a9 100644
--- a/backend/app/services/command_service.py
+++ b/backend/app/services/command_service.py
@@ -108,6 +108,10 @@ def _load_presets_format(self, data: dict) -> None:
)
for cmd in preset_data.get("scheduled_commands", [])
]
+ self._validate_unique_scheduled_command_names(
+ preset_id,
+ scheduled_commands,
+ )
auto_refresh = preset_data.get("auto_refresh_commands", [])
command_execution = self._parse_command_execution_config(
@@ -147,6 +151,27 @@ def _load_presets_format(self, data: dict) -> None:
f"{total_scheduled} total scheduled commands"
)
+ def _validate_unique_scheduled_command_names(
+ self,
+ preset_id: str,
+ scheduled_commands: List[ScheduledCommand],
+ ) -> None:
+ """Reject duplicate scheduled command display names inside one preset."""
+ seen_names: set[str] = set()
+ duplicate_names: set[str] = set()
+
+ for command in scheduled_commands:
+ if command.name in seen_names:
+ duplicate_names.add(command.name)
+ seen_names.add(command.name)
+
+ if duplicate_names:
+ duplicates = ", ".join(sorted(duplicate_names))
+ raise ValueError(
+ "Duplicate scheduled command names are not allowed within "
+ f"preset '{preset_id}': {duplicates}"
+ )
+
def _load_legacy_format(self, data: dict) -> None:
"""Load the legacy flat format (for backwards compatibility).
@@ -173,6 +198,7 @@ def _load_legacy_format(self, data: dict) -> None:
)
for cmd in data.get("scheduled_commands", [])
]
+ self._validate_unique_scheduled_command_names("basic", scheduled_commands)
self._legacy_config = CommandsConfig(
commands=commands,
diff --git a/backend/app/services/labgrid_client.py b/backend/app/services/labgrid_client.py
index 89e7e99..e72f8d7 100644
--- a/backend/app/services/labgrid_client.py
+++ b/backend/app/services/labgrid_client.py
@@ -10,6 +10,7 @@
import asyncio
import contextlib
+import fnmatch
import logging
import os
import socket
@@ -641,27 +642,21 @@ def _get_place_resource_entries(
if matched_resources:
for exporter_name, resource_key in matched_resources:
exporter_resources = self._resources_cache.get(exporter_name, {})
- if resource_key is None:
- for res_type, res_data in exporter_resources.items():
- entry_key = (exporter_name, res_type)
- if entry_key in seen_entries:
- continue
- seen_entries.add(entry_key)
- entries.append((exporter_name, res_type, res_data))
- continue
-
- res_data = exporter_resources.get(resource_key)
- if res_data is None:
- continue
-
- entry_key = (exporter_name, resource_key)
- if entry_key in seen_entries:
- continue
- seen_entries.add(entry_key)
- entries.append((exporter_name, resource_key, res_data))
-
- if entries:
- return entries
+ for resolved_key in self._resolve_matched_resource_keys(
+ exporter_resources,
+ resource_key,
+ ):
+ res_data = exporter_resources.get(resolved_key)
+ if res_data is None:
+ continue
+
+ entry_key = (exporter_name, resolved_key)
+ if entry_key in seen_entries:
+ continue
+ seen_entries.add(entry_key)
+ entries.append((exporter_name, resolved_key, res_data))
+
+ return entries
for exporter_name in exporters:
exporter_resources = self._resources_cache.get(exporter_name, {})
@@ -715,18 +710,20 @@ def _extract_match_resource(
) -> Optional[Tuple[str, Optional[str]]]:
"""Best-effort exporter/resource extraction from a labgrid match entry."""
if isinstance(match, str):
- parts = [part for part in match.split("/") if part]
- if not parts:
+ exporter_name, resource_key = self._parse_string_match(match)
+ if not exporter_name:
return None
- exporter_name = parts[0]
- resource_key = "/".join(parts[1:]) or None
return (exporter_name, resource_key)
exporter_name = self._extract_match_exporter(match)
if not exporter_name:
- return None
+ return self._parse_match_fallback(match)
resource_key = self._extract_match_resource_key(match, exporter_name)
+ if resource_key is None:
+ fallback_exporter, fallback_resource_key = self._parse_match_fallback(match)
+ if fallback_exporter == exporter_name:
+ resource_key = fallback_resource_key
return (exporter_name, resource_key)
def _extract_match_resource_key(
@@ -754,6 +751,9 @@ def _extract_match_resource_key(
break
if not raw_resource:
+ fallback_exporter, fallback_resource_key = self._parse_match_fallback(match)
+ if fallback_exporter == exporter_name:
+ return fallback_resource_key
return None
if raw_resource.startswith(f"{exporter_name}/"):
@@ -764,27 +764,120 @@ def _extract_match_resource_key(
def _extract_match_exporter(self, match: Any) -> Optional[str]:
"""Best-effort exporter extraction from a labgrid place match entry."""
if isinstance(match, str):
- return match.split("/", 1)[0] if match else None
+ exporter_name, _ = self._parse_string_match(match)
+ return exporter_name
if isinstance(match, dict):
for key in ("exporter", "name"):
value = match.get(key)
- if isinstance(value, str):
+ if isinstance(value, str) and value and value != "*":
return value
- return None
+ fallback_exporter, _ = self._parse_match_fallback(match)
+ return fallback_exporter
if isinstance(match, (list, tuple)):
for value in match:
if isinstance(value, str) and value in self._resources_cache:
return value
- return None
+ fallback_exporter, _ = self._parse_match_fallback(match)
+ return fallback_exporter
for attr in ("exporter", "name"):
value = getattr(match, attr, None)
- if isinstance(value, str):
+ if isinstance(value, str) and value and value != "*":
return value
- return None
+ fallback_exporter, _ = self._parse_match_fallback(match)
+ return fallback_exporter
+
+ def _parse_string_match(self, match: str) -> Tuple[Optional[str], Optional[str]]:
+ """Parse string-based labgrid matches, including wildcard variants."""
+ parts = [part for part in match.split("/") if part]
+ if not parts:
+ return (None, None)
+
+ exporter_index = self._find_exporter_index(parts)
+ if exporter_index is None:
+ return (None, None)
+
+ exporter_name = parts[exporter_index]
+ resource_key = "/".join(parts[exporter_index + 1 :]).strip("/") or None
+ return (exporter_name, self._normalize_match_resource_pattern(resource_key))
+
+ def _parse_match_fallback(
+ self, match: Any
+ ) -> Tuple[Optional[str], Optional[str]]:
+ """Fallback parser for match objects whose useful data only exists in their repr()."""
+ for candidate in (str(match), repr(match)):
+ if not isinstance(candidate, str) or "/" not in candidate:
+ continue
+
+ exporter_name, resource_key = self._parse_string_match(candidate)
+ if exporter_name:
+ return (exporter_name, resource_key)
+
+ return (None, None)
+
+ def _find_exporter_index(self, parts: List[str]) -> Optional[int]:
+ """Locate the exporter segment inside a match path."""
+ for index, part in enumerate(parts):
+ if part in self._resources_cache:
+ return index
+
+ non_wildcard_indices = [
+ index for index, part in enumerate(parts) if part and part != "*"
+ ]
+ if not non_wildcard_indices:
+ return None
+
+ if parts[0] == "*":
+ return non_wildcard_indices[0]
+
+ return 0
+
+ def _normalize_match_resource_pattern(
+ self, resource_key: Optional[str]
+ ) -> Optional[str]:
+ """Normalize resource patterns from coordinator match strings."""
+ if not resource_key:
+ return None
+
+ normalized = resource_key.strip("/")
+ if normalized in {"", "*"}:
+ return None
+
+ if normalized.startswith("default/"):
+ normalized = normalized[len("default/") :]
+
+ return normalized or None
+
+ def _resolve_matched_resource_keys(
+ self,
+ exporter_resources: Dict[str, Any],
+ resource_key: Optional[str],
+ ) -> List[str]:
+ """Resolve exact or wildcard resource patterns against cached resources."""
+ if resource_key is None:
+ return list(exporter_resources.keys())
+
+ resolved_keys: List[str] = []
+ for candidate_key in exporter_resources:
+ candidate_aliases = {candidate_key}
+ if "/" not in candidate_key:
+ candidate_aliases.add(f"default/{candidate_key}")
+
+ if "*" in resource_key:
+ if any(
+ fnmatch.fnmatch(candidate_alias, resource_key)
+ for candidate_alias in candidate_aliases
+ ):
+ resolved_keys.append(candidate_key)
+ continue
+
+ if resource_key in candidate_aliases:
+ resolved_keys.append(candidate_key)
+
+ return resolved_keys
async def acquire_target(self, place_name: str) -> bool:
"""Acquire a target for command execution.
diff --git a/backend/commands.yaml b/backend/commands.yaml
index 5305ae2..3cea1db 100644
--- a/backend/commands.yaml
+++ b/backend/commands.yaml
@@ -134,6 +134,11 @@ presets:
- name: "System Time"
command: "date"
description: "Current system time"
+ scheduled_commands:
+ - name: "Uptime"
+ command: "uptime -p"
+ interval_seconds: 180
+ description: "System uptime (updates every 3 minutes)"
staging_serial_fallback:
name: "Staging Serial With SSH Fallback"
@@ -154,6 +159,11 @@ presets:
- name: "System Time"
command: "date"
description: "Current system time"
+ scheduled_commands:
+ - name: "Uptime"
+ command: "uptime -p"
+ interval_seconds: 180
+ description: "System uptime (updates every 3 minutes)"
staging_ssh_key:
name: "Staging SSH With Key"
@@ -170,6 +180,11 @@ presets:
- name: "System Time"
command: "date"
description: "Current system time"
+ scheduled_commands:
+ - name: "Uptime"
+ command: "uptime -p"
+ interval_seconds: 180
+ description: "System uptime (updates every 3 minutes)"
staging_ssh_password:
name: "Staging SSH With Password"
@@ -184,3 +199,8 @@ presets:
- name: "System Time"
command: "date"
description: "Current system time"
+ scheduled_commands:
+ - name: "Uptime"
+ command: "uptime -p"
+ interval_seconds: 180
+ description: "System uptime (updates every 3 minutes)"
diff --git a/backend/tests/test_command_service_presets.py b/backend/tests/test_command_service_presets.py
index 3ce7cf4..cfad5c3 100644
--- a/backend/tests/test_command_service_presets.py
+++ b/backend/tests/test_command_service_presets.py
@@ -191,6 +191,45 @@ def test_get_commands_for_preset(self, presets_yaml_content: str):
finally:
os.unlink(f.name)
+ def test_duplicate_scheduled_command_names_in_same_preset_are_rejected(
+ self,
+ caplog: pytest.LogCaptureFixture,
+ ):
+ """Duplicate scheduled command display names inside one preset should fail loading."""
+ duplicate_yaml_content = """
+default_preset: basic
+
+presets:
+ basic:
+ name: "Basic"
+ description: "Standard Linux commands"
+ scheduled_commands:
+ - name: "Version"
+ command: "cat /etc/version"
+ interval_seconds: 60
+ description: "OS version"
+ - name: "Version"
+ command: "cat /etc/os-release"
+ interval_seconds: 120
+ description: "OS release"
+"""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
+ f.write(duplicate_yaml_content)
+ f.flush()
+
+ try:
+ service = CommandService(commands_file=f.name)
+ service.load()
+
+ assert service.get_presets() == []
+ assert service.get_scheduled_commands() == []
+ assert (
+ "Duplicate scheduled command names are not allowed within preset"
+ in caplog.text
+ )
+ finally:
+ os.unlink(f.name)
+
def test_get_scheduled_commands_for_preset(self, presets_yaml_content: str):
"""Test getting scheduled commands for a specific preset."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
@@ -208,6 +247,37 @@ def test_get_scheduled_commands_for_preset(self, presets_yaml_content: str):
finally:
os.unlink(f.name)
+ def test_duplicate_scheduled_command_names_in_legacy_format_are_rejected(
+ self,
+ caplog: pytest.LogCaptureFixture,
+ ):
+ """Duplicate scheduled command display names should also fail in legacy mode."""
+ duplicate_yaml_content = """
+scheduled_commands:
+ - name: "Version"
+ command: "cat /etc/version"
+ interval_seconds: 60
+ - name: "Version"
+ command: "cat /etc/os-release"
+ interval_seconds: 120
+"""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
+ f.write(duplicate_yaml_content)
+ f.flush()
+
+ try:
+ service = CommandService(commands_file=f.name)
+ service.load()
+
+ assert service.get_presets() == []
+ assert service.get_scheduled_commands() == []
+ assert (
+ "Duplicate scheduled command names are not allowed within preset"
+ in caplog.text
+ )
+ finally:
+ os.unlink(f.name)
+
def test_get_auto_refresh_commands_for_preset(self, presets_yaml_content: str):
"""Test getting auto-refresh commands for a specific preset."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
diff --git a/backend/tests/test_labgrid_client.py b/backend/tests/test_labgrid_client.py
index 8b33431..6d3570e 100644
--- a/backend/tests/test_labgrid_client.py
+++ b/backend/tests/test_labgrid_client.py
@@ -123,6 +123,20 @@ async def test_resolve_hostname_to_ip_returns_none_for_invalid(
class TestLabgridClientWithMockedSession:
"""Test cases with mocked labgrid ClientSession."""
+ class _WildcardMatch:
+ """Simple stand-in for labgrid ResourceMatch repr-based wildcard objects."""
+
+ exporter = "*"
+ name = None
+
+ def __init__(self, pattern: str):
+ self._pattern = pattern
+
+ def __str__(self) -> str:
+ return self._pattern
+
+ __repr__ = __str__
+
def _create_mock_resource_entry(self, cls_name, params, acquired, avail):
"""Create a mock ResourceEntry object that mimics labgrid's structure."""
mock_entry = MagicMock()
@@ -426,6 +440,159 @@ def test_get_place_resource_entries_only_returns_matched_resources(
)
]
+ def test_get_place_resource_entries_resolve_wildcard_exporter_match(
+ self, connected_client: LabgridClient
+ ):
+ """Wildcard matches should still resolve the real exporter name."""
+ connected_client._resources_cache = {
+ "exporter-shared": {
+ "console/NetworkSerialPort": {
+ "cls": "NetworkSerialPort",
+ "params": {"host": "192.168.1.100", "port": 5000},
+ "acquired": None,
+ "avail": True,
+ },
+ "ssh/NetworkService": {
+ "cls": "NetworkService",
+ "params": {"address": "192.168.1.100"},
+ "acquired": None,
+ "avail": True,
+ },
+ }
+ }
+ connected_client._places_cache = {
+ "custom-place": {
+ "name": "custom-place",
+ "acquired": None,
+ "comment": "",
+ "tags": {},
+ "matches": ["*/exporter-shared/*"],
+ }
+ }
+
+ entries = connected_client.get_place_resource_entries("custom-place")
+
+ assert entries == [
+ (
+ "exporter-shared",
+ "console/NetworkSerialPort",
+ connected_client._resources_cache["exporter-shared"][
+ "console/NetworkSerialPort"
+ ],
+ ),
+ (
+ "exporter-shared",
+ "ssh/NetworkService",
+ connected_client._resources_cache["exporter-shared"][
+ "ssh/NetworkService"
+ ],
+ ),
+ ]
+
+ def test_get_place_resource_entries_resolve_specific_resource_with_wildcard_prefix(
+ self, connected_client: LabgridClient
+ ):
+ """Wildcard-prefixed specific matches should still scope to the exact resource."""
+ connected_client._resources_cache = {
+ "exporter-shared": {
+ "console/NetworkSerialPort": {
+ "cls": "NetworkSerialPort",
+ "params": {"host": "192.168.1.100", "port": 5000},
+ "acquired": None,
+ "avail": True,
+ },
+ "ssh/NetworkService": {
+ "cls": "NetworkService",
+ "params": {"address": "192.168.1.100"},
+ "acquired": None,
+ "avail": True,
+ },
+ }
+ }
+ connected_client._places_cache = {
+ "custom-place": {
+ "name": "custom-place",
+ "acquired": None,
+ "comment": "",
+ "tags": {},
+ "matches": ["*/exporter-shared/console/NetworkSerialPort"],
+ }
+ }
+
+ entries = connected_client.get_place_resource_entries("custom-place")
+
+ assert entries == [
+ (
+ "exporter-shared",
+ "console/NetworkSerialPort",
+ connected_client._resources_cache["exporter-shared"][
+ "console/NetworkSerialPort"
+ ],
+ )
+ ]
+
+ def test_parse_string_match_resolves_staging_wildcard_exporter(
+ self, connected_client: LabgridClient
+ ):
+ """The staging match syntax */exporter/* should resolve exporter and wildcard resource."""
+ connected_client._resources_cache = {"exporter-1": {}}
+
+ exporter_name, resource_key = connected_client._parse_string_match(
+ "*/exporter-1/*"
+ )
+
+ assert exporter_name == "exporter-1"
+ assert resource_key is None
+
+ def test_get_place_resource_entries_resolve_wildcard_match_objects(
+ self, connected_client: LabgridClient
+ ):
+ """ResourceMatch-like objects should fall back to their string representation."""
+ connected_client._resources_cache = {
+ "exporter-shared": {
+ "console/NetworkSerialPort": {
+ "cls": "NetworkSerialPort",
+ "params": {"host": "192.168.1.100", "port": 5000},
+ "acquired": None,
+ "avail": True,
+ },
+ "ssh/NetworkService": {
+ "cls": "NetworkService",
+ "params": {"address": "192.168.1.100"},
+ "acquired": None,
+ "avail": True,
+ },
+ }
+ }
+ connected_client._places_cache = {
+ "custom-place": {
+ "name": "custom-place",
+ "acquired": None,
+ "comment": "",
+ "tags": {},
+ "matches": [self._WildcardMatch("*/exporter-shared/*")],
+ }
+ }
+
+ entries = connected_client.get_place_resource_entries("custom-place")
+
+ assert entries == [
+ (
+ "exporter-shared",
+ "console/NetworkSerialPort",
+ connected_client._resources_cache["exporter-shared"][
+ "console/NetworkSerialPort"
+ ],
+ ),
+ (
+ "exporter-shared",
+ "ssh/NetworkService",
+ connected_client._resources_cache["exporter-shared"][
+ "ssh/NetworkService"
+ ],
+ ),
+ ]
+
@pytest.mark.asyncio
async def test_refresh_cache_keeps_empty_params_resources_online(
self, connected_client: LabgridClient
From 22f0537a96599e86720e157c6ecb764ef308752a Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:19:22 +0100
Subject: [PATCH 19/20] fix(frontend): stabilize staging dev server connections
---
docker-compose.yml | 8 ++++++--
frontend/public/env-config.js | 1 +
frontend/src/hooks/useWebSocket.ts | 16 ++++++++++++++--
frontend/vite.config.ts | 13 ++++++++++++-
4 files changed, 33 insertions(+), 5 deletions(-)
create mode 100644 frontend/public/env-config.js
diff --git a/docker-compose.yml b/docker-compose.yml
index 95a7b65..13d351a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -69,8 +69,12 @@ services:
ports:
- "3000:3000"
environment:
- - VITE_API_URL=http://localhost:8000
- - VITE_WS_URL=ws://localhost:8000/api/ws
+ - VITE_API_URL=/api
+ - VITE_WS_URL=/api/ws
+ - VITE_API_PROXY_TARGET=http://backend:8000
+ - VITE_HMR_HOST=localhost
+ - VITE_HMR_CLIENT_PORT=3000
+ - VITE_HMR_PROTOCOL=ws
- CHOKIDAR_USEPOLLING=true
depends_on:
- backend
diff --git a/frontend/public/env-config.js b/frontend/public/env-config.js
new file mode 100644
index 0000000..a006be1
--- /dev/null
+++ b/frontend/public/env-config.js
@@ -0,0 +1 @@
+window.ENV = window.ENV || {};
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts
index ec68ec8..e3fa532 100644
--- a/frontend/src/hooks/useWebSocket.ts
+++ b/frontend/src/hooks/useWebSocket.ts
@@ -37,6 +37,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRes
const wsRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef(null);
+ const initialConnectTimeoutRef = useRef(null);
const callbacksRef = useRef(options);
// Flag to track intentional closes (cleanup, unmount) vs unexpected disconnects
const intentionalCloseRef = useRef(false);
@@ -52,6 +53,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRes
}
}, []);
+ const clearInitialConnectTimeout = useCallback(() => {
+ if (initialConnectTimeoutRef.current !== null) {
+ window.clearTimeout(initialConnectTimeoutRef.current);
+ initialConnectTimeoutRef.current = null;
+ }
+ }, []);
+
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
@@ -176,9 +184,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRes
);
useEffect(() => {
- connect();
+ initialConnectTimeoutRef.current = window.setTimeout(() => {
+ initialConnectTimeoutRef.current = null;
+ connect();
+ }, 0);
return () => {
+ clearInitialConnectTimeout();
clearReconnectTimeout();
// Mark as intentional close to prevent error logging and reconnection attempts
intentionalCloseRef.current = true;
@@ -187,7 +199,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRes
wsRef.current = null;
}
};
- }, [connect, clearReconnectTimeout]);
+ }, [connect, clearInitialConnectTimeout, clearReconnectTimeout]);
return {
connected,
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index daec086..5924f4d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -7,9 +7,20 @@ export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
+ hmr:
+ process.env.VITE_HMR_HOST || process.env.VITE_HMR_CLIENT_PORT
+ ? {
+ protocol: process.env.VITE_HMR_PROTOCOL || 'ws',
+ host: process.env.VITE_HMR_HOST || 'localhost',
+ clientPort: Number(process.env.VITE_HMR_CLIENT_PORT || '3000'),
+ }
+ : undefined,
proxy: {
'/api': {
- target: process.env.VITE_API_URL || 'http://localhost:8000',
+ target:
+ process.env.VITE_API_PROXY_TARGET ||
+ process.env.VITE_API_URL ||
+ 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
From ed1c9c7317f53f70478b17d92ea9fe3e7d91f2e8 Mon Sep 17 00:00:00 2001
From: Gerrri <28703799+Gerrri@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:22:05 +0100
Subject: [PATCH 20/20] chore(release): bump version to 0.1.7
---
backend/app/main.py | 2 +-
frontend/package-lock.json | 4 ++--
frontend/package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 1e18d4f..88a610c 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -299,7 +299,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title="Labgrid Dashboard API",
description="REST API for Labgrid Dashboard - Monitor and interact with DUTs",
- version="0.1.6",
+ version="0.1.7",
lifespan=lifespan,
)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5a5a902..27325bc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "labgrid-dashboard-frontend",
- "version": "0.1.6",
+ "version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "labgrid-dashboard-frontend",
- "version": "0.1.6",
+ "version": "0.1.7",
"dependencies": {
"axios": "^1.7.0",
"react": "^19.2.0",
diff --git a/frontend/package.json b/frontend/package.json
index 13b8ca6..e403d9c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "labgrid-dashboard-frontend",
"private": true,
- "version": "0.1.6",
+ "version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite",