feat(fleet): edit apparatus name independently of onboard availability
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
|
||||
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
|
||||
<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">
|
||||
<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}/>
|
||||
|
||||
@@ -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 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 <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">
|
||||
{detail?<>
|
||||
<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>}
|
||||
</div>;
|
||||
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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