feat(fleet): edit apparatus name independently of onboard availability

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 22:28:22 +03:00
parent ee7576b7b4
commit aecae4a986
8 changed files with 147 additions and 6 deletions
@@ -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}
+15
View File
@@ -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 {
+16
View File
@@ -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:
+79
View File
@@ -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