From aecae4a98688d621083b15aded886ae9e9b4ccd9 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 25 Sep 2026 22:28:22 +0300 Subject: [PATCH] feat(fleet): edit apparatus name independently of onboard availability --- .../src/core/fleet/useFleet.ts | 5 +- .../src/workspaces/fleet/VehicleEquipment.tsx | 5 +- .../fleet/VehicleIdentitySettings.tsx | 27 +++++++ .../src/workspaces/fleet/VehicleSensors.tsx | 4 +- .../workspaces/fleet/VehiclesWorkspace.tsx | 2 +- src/k1link/fleet/registry.py | 15 ++++ src/k1link/web/fleet_api.py | 16 ++++ tests/fleet/test_identity.py | 79 +++++++++++++++++++ 8 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 apps/control-station/src/workspaces/fleet/VehicleIdentitySettings.tsx create mode 100644 tests/fleet/test_identity.py diff --git a/apps/control-station/src/core/fleet/useFleet.ts b/apps/control-station/src/core/fleet/useFleet.ts index 32b0882..c76a9cc 100644 --- a/apps/control-station/src/core/fleet/useFleet.ts +++ b/apps/control-station/src/core/fleet/useFleet.ts @@ -57,5 +57,8 @@ export function useFleet() { events.onerror = unavailable; return () => { active = false; events.close(); if (fallback) clearInterval(fallback); }; }, []); - return { items, error, refresh }; + const acceptVehicle = useCallback((vehicle: Vehicle) => { + setItems(current => current?.map(item => item.id === vehicle.id ? vehicle : item) ?? null); + }, []); + return { items, error, refresh, acceptVehicle }; } diff --git a/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx b/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx index 7740f05..01c036b 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleEquipment.tsx @@ -3,9 +3,10 @@ import {Button,Icon,IconButton,Inspector,LoadingRegion} from '@nodedc/ui-react'; import type {BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout'; import type {SensorBoardContent} from '../../../../../packages/sensor-ui/src/SensorWorkspace'; import type {Vehicle} from '../../core/fleet/useFleet'; +import {VehicleIdentitySettings} from './VehicleIdentitySettings'; import {BoardComputerRow} from './BoardComputer'; -export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,onSettings,onMonitor}:{vehicle:Vehicle;enabled:boolean;layout:BoardLayoutStore;content:SensorBoardContent;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void}){ +export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;layout:BoardLayoutStore;content:SensorBoardContent;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){ const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot); useEffect(()=>{void layout.load();},[layout]); const computerOpen=state.value.open_sections.includes('computer'); @@ -16,7 +17,7 @@ export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,o {state.error&&

{state.error}

} id!=='computer')} onOpenSectionsChange={changeSections} sections={[ - {id:'settings',label:'Настройки аппарата',icon:,disabled:!state.ready,content:content.settings}, + {id:'settings',label:'Настройки аппарата',icon:,disabled:!state.ready,content:
{content.settings}
}, {id:'devices',label:'Оборудование',icon:,disabled:!state.ready,content:
Бортовой компьютер и подключённые к нему устройства
void content.refresh()}>
diff --git a/apps/control-station/src/workspaces/fleet/VehicleIdentitySettings.tsx b/apps/control-station/src/workspaces/fleet/VehicleIdentitySettings.tsx new file mode 100644 index 0000000..a19f1b1 --- /dev/null +++ b/apps/control-station/src/workspaces/fleet/VehicleIdentitySettings.tsx @@ -0,0 +1,27 @@ +import {useEffect,useState} from 'react'; +import {Button,SettingsCard,TextField} from '@nodedc/ui-react'; +import {fleetRequest,type Vehicle} from '../../core/fleet/useFleet'; + +export function VehicleIdentitySettings({vehicle,onUpdated}:{vehicle:Vehicle;onUpdated:(vehicle:Vehicle)=>void}){ + const [base,setBase]=useState(vehicle),[name,setName]=useState(vehicle.name); + const [saving,setSaving]=useState(false),[message,setMessage]=useState(''),[error,setError]=useState(''); + const dirty=name!==base.name; + useEffect(()=>{if(!dirty&&!saving){setBase(vehicle);setName(vehicle.name);}},[vehicle,dirty,saving]); + const reset=()=>{setBase(vehicle);setName(vehicle.name);setError('');setMessage('');}; + const save=async()=>{ + if(saving||!name.trim()||!dirty)return; + setSaving(true);setError('');setMessage(''); + try{ + const updated=await fleetRequest(`/${encodeURIComponent(vehicle.id)}`,'PATCH',{name:name.trim(),expected_revision:base.revision}); + onUpdated(updated);setBase(updated);setName(updated.name);setMessage('Название сохранено'); + }catch(reason){setError(reason instanceof Error?reason.message:'Не удалось сохранить название.');} + finally{setSaving(false);} + }; + return +
{event.preventDefault();void save();}}> + {setName(event.target.value);setMessage('');}}/> +
{dirty&&}
+ {error&&

{error}

}{message&&

{message}

} + +
; +} diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index 79fa4d7..ee571fe 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -6,11 +6,11 @@ import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorks import {createFleetSensorTransport} from '../../core/fleet/sensorTransport'; import type {Vehicle} from '../../core/fleet/useFleet'; import {VehicleEquipment} from './VehicleEquipment'; -export function VehicleSensors({vehicle,enabled,onAddComputer,onSettings,onMonitor}:{vehicle:Vehicle;enabled:boolean;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void}){ +export function VehicleSensors({vehicle,enabled,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){ const vehicleID=vehicle.id; const {registry}=useDevicePluginHost(); const sensorContributions=registry.sensorContributions; const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]); const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]); - return }/>; + return }/>; } diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index 3575266..41a02af 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -29,7 +29,7 @@ export function VehiclesWorkspace({headerToolsHost,headerTitleHost}:{headerTools :
{detail?<> setSelected(null)} host={headerToolsHost}/> - setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/> + setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/> :!fleet.items?:fleet.items.length===0?:{fleet.items.map(item=>
  • } title={item.name} description={platformLabel(item.platform)} status={{fleet.error?'Нет свежих данных':`БК: ${boardStatus(item)}`}} actions={<>setSelected(item.id)}>{setSelected(item.id);setObservationOpen(true);}}>}/>
  • )}
    }
    ; return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content} diff --git a/src/k1link/fleet/registry.py b/src/k1link/fleet/registry.py index f874ede..7fc14a8 100644 --- a/src/k1link/fleet/registry.py +++ b/src/k1link/fleet/registry.py @@ -273,6 +273,21 @@ class FleetRegistry: "core_endpoint": row["binding"]["endpoint"], } + def rename(self, identifier: str, name: str, expected_revision: int) -> dict: + """Update operator metadata without contacting or re-enrolling the board.""" + if (not name.strip() or len(name.strip()) > 80 + or any(ord(c) < 32 or 127 <= ord(c) < 160 for c in name)): + raise PairingError("Введите название аппарата от 1 до 80 символов без управляющих знаков.") + with self.lock: + row = self.find(identifier) + if row["name"] == name.strip(): + return self.public(row) # Safe retry after a lost response. + if row["revision"] != expected_revision: + raise PairingError("Аппарат изменился. Отмените правку и повторите её по свежим данным.") + row.update(name=name.strip(), revision=row["revision"] + 1) + self.save(row) + return self.public(row) + def listing(self): with self.lock: return { diff --git a/src/k1link/web/fleet_api.py b/src/k1link/web/fleet_api.py index f130981..5a4f66c 100644 --- a/src/k1link/web/fleet_api.py +++ b/src/k1link/web/fleet_api.py @@ -54,6 +54,12 @@ class AttachBoardRequest(BaseModel): expected_revision: int = Field(ge=1, strict=True) +class RenameRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=80) + expected_revision: int = Field(ge=1, strict=True) + + router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"]) @@ -163,6 +169,16 @@ def fleet_add( raise HTTPException(409, str(error)) from None +@router.patch("/{vehicle_id}") +def fleet_rename(vehicle_id: str, body: RenameRequest, response: Response, + fleet: Annotated[FleetRegistry, Depends(local_operator)]): + response.headers["Cache-Control"] = "no-store" + try: + return fleet.rename(vehicle_id, body.name, body.expected_revision) + except PairingError as error: + raise HTTPException(409, str(error)) from None + + @router.delete("/{vehicle_id}") def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]): try: diff --git a/tests/fleet/test_identity.py b/tests/fleet/test_identity.py new file mode 100644 index 0000000..1c497d6 --- /dev/null +++ b/tests/fleet/test_identity.py @@ -0,0 +1,79 @@ +"""Apparatus metadata is independent of its powered/offline board.""" +import sqlite3 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from test_pairing import setup, create, cert, heartbeat +from k1link.fleet.registry import FleetRegistry +from k1link.fleet.trust import PairingError +from k1link.web.fleet_api import router + + +def test_offline_rename_persists_without_touching_board_or_authority(setup): + fleet, _, _, calls = setup + public, _ = create(setup) + fleet.advance(public['id']) + before = fleet.find(public['id']) + network_calls = len(calls) + result = fleet.rename(before['id'], ' New rover ', before['revision']) + expected = {**before, 'name': 'New rover', 'revision': before['revision']+1} + assert fleet.find(before['id']) == expected + assert result['connectivity'] == 'offline' + assert len(calls) == network_calls + assert fleet.rename(before['id'], 'New rover', before['revision']) == result + reopened = FleetRegistry(fleet.root) + try: + assert reopened.find(before['id']) == expected + finally: + reopened.close() + # The existing board certificate and heartbeat continue to work unchanged. + assert fleet.receive(cert(before), '/v1/node/heartbeat', heartbeat(before))[0] == 200 + assert fleet.find(before['id'])['name'] == 'New rover' + + +def test_stale_editor_cannot_overwrite_newer_name(setup): + fleet = setup[0] + row, _ = create(setup) + fleet.rename(row['id'], 'Other editor', row['revision']) + with pytest.raises(PairingError, match='изменился'): + fleet.rename(row['id'], 'Stale edit', row['revision']) + assert fleet.find(row['id'])['name'] == 'Other editor' + + +@pytest.mark.parametrize('name', ['', ' ', 'a'*81, 'bad\nname', 'bad\x7fname']) +def test_invalid_name_does_not_change_saved_apparatus(setup, name): + fleet = setup[0] + row, _ = create(setup) + before = fleet.find(row['id']) + with pytest.raises(PairingError): + fleet.rename(row['id'], name, row['revision']) + assert fleet.find(row['id']) == before + + +def test_database_failure_keeps_previous_name(setup): + fleet = setup[0] + row, _ = create(setup) + fleet.db.execute("CREATE TRIGGER fail_rename BEFORE UPDATE ON vehicles BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END") + with pytest.raises(sqlite3.IntegrityError): + fleet.rename(row['id'], 'Unsaved', row['revision']) + assert fleet.find(row['id'])['name'] == row['name'] + + +def test_rename_endpoint_is_local_scoped_and_returns_public_metadata(setup): + fleet = setup[0] + row, _ = create(setup) + app = FastAPI(); app.state.fleet = fleet; app.include_router(router) + path = '/api/v1/fleet/'+row['id'] + body = {'name':'Updated rover','expected_revision':row['revision']} + with TestClient(app, base_url='http://127.0.0.1:8000', client=('127.0.0.1',55555)) as client: + assert client.patch(path, json=body, headers={'Origin':'https://attacker.test'}).status_code == 403 + assert client.patch(path, json={**body,'node_id':'injected'}).status_code == 422 + assert client.patch(path, json={**body,'expected_revision':True}).status_code == 422 + response = client.patch(path, json=body) + assert response.status_code == 200 + assert response.headers['cache-control'] == 'no-store' + assert response.json()['name'] == 'Updated rover' + assert fleet.find(row['id'])['binding']['binding_id'] not in response.text + assert client.patch(path, json={**body,'name':'Stale'}).status_code == 409