Implement Harbor gateway device ecosystem support
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
type Device,
|
||||
type DevicePolicy,
|
||||
type DeviceSnapshot,
|
||||
type DeviceTag,
|
||||
} from './deviceSnapshot.js';
|
||||
|
||||
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||
@@ -21,6 +22,9 @@ interface DevicesFeatureOptions {
|
||||
refreshDevices: () => Promise<unknown>;
|
||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
|
||||
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
|
||||
deleteDeviceTag: (id: string, expectedRevision: number) => Promise<unknown>;
|
||||
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -37,12 +41,21 @@ function requestError(value: unknown): RequestError {
|
||||
return { code: typeof value.code === 'string' ? value.code : undefined };
|
||||
}
|
||||
|
||||
const sameStringList = (left: string[], right: string[]) => (
|
||||
left.length === right.length && left.every((value, index) => value === right[index])
|
||||
);
|
||||
|
||||
const tagNameKey = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
|
||||
|
||||
export function useDevicesFeature({
|
||||
isGateway,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
resetDeviceTraffic,
|
||||
updateDevice: requestDeviceUpdate,
|
||||
createDeviceTag,
|
||||
renameDeviceTag,
|
||||
deleteDeviceTag,
|
||||
setDevicePolicy,
|
||||
}: DevicesFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -52,6 +65,8 @@ export function useDevicesFeature({
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||
const [savingId, setSavingId] = useState('');
|
||||
const [tagSavingId, setTagSavingId] = useState('');
|
||||
const [tagError, setTagError] = useState<unknown>(null);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
@@ -140,6 +155,120 @@ export function useDevicesFeature({
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceTags(device: Device, tagIds: string[], baselineTagIds: string[]) {
|
||||
if (!snapshot) return false;
|
||||
setTagSavingId(device.id);
|
||||
setTagError(null);
|
||||
const currentDevice = snapshot.devices.find(({ id }) => id === device.id);
|
||||
if (!currentDevice || !sameStringList(currentDevice.tagIds, baselineTagIds)) {
|
||||
setTagError(new Error('Device tags changed'));
|
||||
setTagSavingId('');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
const knownTagIds = new Set(latest.tags.map(({ id }) => id));
|
||||
if (!latestDevice || !sameStringList(latestDevice.tagIds, baselineTagIds)
|
||||
|| tagIds.some((tagId) => !knownTagIds.has(tagId))) throw caught;
|
||||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function createTag(name: string) {
|
||||
if (!snapshot) return false;
|
||||
setTagSavingId('create');
|
||||
setTagError(null);
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await createDeviceTag(name, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const nameKey = tagNameKey(name);
|
||||
if (latest.tags.length >= 32 || latest.tags.some((tag) => tagNameKey(tag.name) === nameKey)) throw caught;
|
||||
next = parseDeviceSnapshot(await createDeviceTag(name, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTag(tag: DeviceTag, name: string, baselineName: string) {
|
||||
if (!snapshot) return false;
|
||||
setTagSavingId(tag.id);
|
||||
setTagError(null);
|
||||
if (snapshot.tags.find(({ id }) => id === tag.id)?.name !== baselineName) {
|
||||
setTagError(new Error('Device tag changed'));
|
||||
setTagSavingId('');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const latestTag = latest.tags.find(({ id }) => id === tag.id);
|
||||
if (!latestTag || latestTag.name !== baselineName) throw caught;
|
||||
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag(tag: DeviceTag): Promise<'saved' | 'conflict' | 'failed'> {
|
||||
if (!snapshot) return 'failed';
|
||||
setTagSavingId(tag.id);
|
||||
setTagError(null);
|
||||
try {
|
||||
publish(parseDeviceSnapshot(await deleteDeviceTag(tag.id, snapshot.revision)));
|
||||
return 'saved';
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
if (requestError(caught).code === 'STATE_CONFLICT') {
|
||||
try {
|
||||
publish(parseDeviceSnapshot(await listDevices()));
|
||||
} catch {
|
||||
// Preserve the conflict as the actionable error.
|
||||
}
|
||||
return 'conflict';
|
||||
}
|
||||
return 'failed';
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResetTraffic() {
|
||||
if (!snapshot) return;
|
||||
setResetting(true);
|
||||
@@ -180,6 +309,7 @@ export function useDevicesFeature({
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (resetOpen) return;
|
||||
if (document.querySelector('.client-devices-rail.is-open, .client-device-tag-popover, .client-confirmation-popup.is-open')) return;
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
@@ -206,6 +336,8 @@ export function useDevicesFeature({
|
||||
refreshing,
|
||||
refreshCycle,
|
||||
savingId,
|
||||
tagSavingId,
|
||||
tagError,
|
||||
resetOpen,
|
||||
resetting,
|
||||
panelRef,
|
||||
@@ -213,6 +345,11 @@ export function useDevicesFeature({
|
||||
closeRef,
|
||||
load,
|
||||
updateDevice,
|
||||
updateDeviceTags,
|
||||
createTag,
|
||||
renameTag,
|
||||
deleteTag,
|
||||
clearTagError: () => setTagError(null),
|
||||
updatePolicy,
|
||||
requestTrafficReset: () => setResetOpen(true),
|
||||
cancelTrafficReset: () => setResetOpen(false),
|
||||
|
||||
Reference in New Issue
Block a user