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
+
+ ;
+}
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