feat(fleet): edit apparatus name independently of onboard availability
This commit is contained in:
@@ -57,5 +57,8 @@ export function useFleet() {
|
|||||||
events.onerror = unavailable;
|
events.onerror = unavailable;
|
||||||
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
|
||||||
import type {SensorBoardContent} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
import type {SensorBoardContent} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {Vehicle} from '../../core/fleet/useFleet';
|
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||||
|
import {VehicleIdentitySettings} from './VehicleIdentitySettings';
|
||||||
import {BoardComputerRow} from './BoardComputer';
|
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);
|
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
|
||||||
useEffect(()=>{void layout.load();},[layout]);
|
useEffect(()=>{void layout.load();},[layout]);
|
||||||
const computerOpen=state.value.open_sections.includes('computer');
|
const computerOpen=state.value.open_sections.includes('computer');
|
||||||
@@ -16,7 +17,7 @@ export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,o
|
|||||||
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
|
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
|
||||||
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
|
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
|
||||||
<Inspector variant="panel" openSections={state.value.open_sections.filter(id=>id!=='computer')} onOpenSectionsChange={changeSections} sections={[
|
<Inspector variant="panel" openSections={state.value.open_sections.filter(id=>id!=='computer')} onOpenSectionsChange={changeSections} sections={[
|
||||||
{id:'settings',label:'Настройки аппарата',icon:<Icon name="sliders"/>,disabled:!state.ready,content:content.settings},
|
{id:'settings',label:'Настройки аппарата',icon:<Icon name="sliders"/>,disabled:!state.ready,content:<div className="sensor-content"><VehicleIdentitySettings key={vehicle.id} vehicle={vehicle} onUpdated={onUpdated}/>{content.settings}</div>},
|
||||||
{id:'devices',label:'Оборудование',icon:<Icon name="apps"/>,disabled:!state.ready,content:<div className="sensor-content">
|
{id:'devices',label:'Оборудование',icon:<Icon name="apps"/>,disabled:!state.ready,content:<div className="sensor-content">
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><span className="sensor-note">Бортовой компьютер и подключённые к нему устройства</span><div className="sensor-actions"><IconButton label="Подключить бортовой компьютер" onClick={onAddComputer}><Icon name="plus"/></IconButton><IconButton label="Обновить оборудование" onClick={()=>void content.refresh()}><Icon name="refresh"/></IconButton></div></div>
|
<div className="sensor-actions sensor-inventory-toolbar"><span className="sensor-note">Бортовой компьютер и подключённые к нему устройства</span><div className="sensor-actions"><IconButton label="Подключить бортовой компьютер" onClick={onAddComputer}><Icon name="plus"/></IconButton><IconButton label="Обновить оборудование" onClick={()=>void content.refresh()}><Icon name="refresh"/></IconButton></div></div>
|
||||||
<div><BoardComputerRow vehicle={vehicle} enabled={enabled} showingSavedDevices={content.showingSavedDevices} open={computerOpen} onToggle={toggleComputer} onSettings={onSettings} onMonitor={onMonitor} onAdd={content.canAddDevice?content.addDevice:undefined}/>
|
<div><BoardComputerRow vehicle={vehicle} enabled={enabled} showingSavedDevices={content.showingSavedDevices} open={computerOpen} onToggle={toggleComputer} onSettings={onSettings} onMonitor={onMonitor} onAdd={content.canAddDevice?content.addDevice:undefined}/>
|
||||||
|
|||||||
@@ -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<Vehicle>(`/${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 <SettingsCard title="Основные параметры">
|
||||||
|
<form className="fleet-form" onSubmit={event=>{event.preventDefault();void save();}}>
|
||||||
|
<TextField label="Название аппарата" value={name} maxLength={80} autoComplete="off" disabled={saving} onChange={event=>{setName(event.target.value);setMessage('');}}/>
|
||||||
|
<div className="sensor-actions"><Button type="submit" disabled={!dirty||!name.trim()||saving}>{saving?'Сохранение…':'Сохранить'}</Button>{dirty&&<Button type="button" variant="secondary" disabled={saving} onClick={reset}>Отмена</Button>}</div>
|
||||||
|
{error&&<p role="alert">{error}</p>}{message&&<p role="status">{message}</p>}
|
||||||
|
</form>
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorks
|
|||||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||||
import type {Vehicle} from '../../core/fleet/useFleet';
|
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||||
import {VehicleEquipment} from './VehicleEquipment';
|
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 vehicleID=vehicle.id;
|
||||||
const {registry}=useDevicePluginHost();
|
const {registry}=useDevicePluginHost();
|
||||||
const sensorContributions=registry.sensorContributions;
|
const sensorContributions=registry.sensorContributions;
|
||||||
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
||||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={`${vehicleID}:${vehicle.node_id}`} transport={transport} enabled={enabled} readOfflineInventory renderBoard={content=><VehicleEquipment vehicle={vehicle} enabled={enabled} layout={layout} content={content} onAddComputer={onAddComputer} onSettings={onSettings} onMonitor={onMonitor}/>}/>;
|
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={`${vehicleID}:${vehicle.node_id}`} transport={transport} enabled={enabled} readOfflineInventory renderBoard={content=><VehicleEquipment vehicle={vehicle} enabled={enabled} layout={layout} content={content} onAddComputer={onAddComputer} onSettings={onSettings} onMonitor={onMonitor} onUpdated={onUpdated}/>}/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function VehiclesWorkspace({headerToolsHost,headerTitleHost}:{headerTools
|
|||||||
:<div className="fleet-workspace">
|
:<div className="fleet-workspace">
|
||||||
{detail?<>
|
{detail?<>
|
||||||
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
|
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
|
||||||
<VehicleSensors vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
|
<VehicleSensors onUpdated={fleet.acceptVehicle} vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
|
||||||
</>:!fleet.items?<LoadingRegion loading={!fleet.error} label="Получение аппаратов"/>:fleet.items.length===0?<SettingsCard title="Аппаратов пока нет" description="Создайте аппарат и подключите его бортовой компьютер по приглашению Mission Core Node."/>:<ResourceList aria-label="Аппараты">{fleet.items.map(item=><li key={item.id}><ResourceRow icon={<Icon name="apps"/>} title={item.name} description={platformLabel(item.platform)} status={<StatusBadge tone={!fleet.error&&item.enrollment==='paired'&&item.connectivity==='online'?'success':'neutral'}>{fleet.error?'Нет свежих данных':`БК: ${boardStatus(item)}`}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={()=>setSelected(item.id)}><Icon name="sliders"/></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={()=>{setSelected(item.id);setObservationOpen(true);}}><Icon name="eye"/></IconButton></>}/></li>)}</ResourceList>}
|
</>:!fleet.items?<LoadingRegion loading={!fleet.error} label="Получение аппаратов"/>:fleet.items.length===0?<SettingsCard title="Аппаратов пока нет" description="Создайте аппарат и подключите его бортовой компьютер по приглашению Mission Core Node."/>:<ResourceList aria-label="Аппараты">{fleet.items.map(item=><li key={item.id}><ResourceRow icon={<Icon name="apps"/>} title={item.name} description={platformLabel(item.platform)} status={<StatusBadge tone={!fleet.error&&item.enrollment==='paired'&&item.connectivity==='online'?'success':'neutral'}>{fleet.error?'Нет свежих данных':`БК: ${boardStatus(item)}`}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={()=>setSelected(item.id)}><Icon name="sliders"/></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={()=>{setSelected(item.id);setObservationOpen(true);}}><Icon name="eye"/></IconButton></>}/></li>)}</ResourceList>}
|
||||||
</div>;
|
</div>;
|
||||||
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content}
|
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content}
|
||||||
|
|||||||
@@ -273,6 +273,21 @@ class FleetRegistry:
|
|||||||
"core_endpoint": row["binding"]["endpoint"],
|
"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):
|
def listing(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ class AttachBoardRequest(BaseModel):
|
|||||||
expected_revision: int = Field(ge=1, strict=True)
|
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"])
|
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +169,16 @@ def fleet_add(
|
|||||||
raise HTTPException(409, str(error)) from None
|
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}")
|
@router.delete("/{vehicle_id}")
|
||||||
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user