Improve device tag editing and version clients
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-31 02:26:24 +03:00
parent 116686a138
commit f4882c53c2
6 changed files with 215 additions and 108 deletions
+107 -58
View File
@@ -61,7 +61,6 @@ type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
interface TagPopoverState {
deviceId: string;
title: string;
baseline: string[];
draft: string[];
catalogKey: string;
@@ -137,6 +136,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [tagPopover, setTagPopover] = useState<TagPopoverState | null>(null);
const [tagPopoverClosing, setTagPopoverClosing] = useState(false);
const [tagAnnouncement, setTagAnnouncement] = useState('');
const [tagMutationCycle, setTagMutationCycle] = useState(0);
const [addingTag, setAddingTag] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [editingTagId, setEditingTagId] = useState('');
const [editingTagName, setEditingTagName] = useState('');
@@ -152,6 +153,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const copyAttempts = useRef(new Map<string, object>());
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const tagPopoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const tagMutationPending = useRef(false);
const railModeAnimation = useRef<Animation | null>(null);
const railModeRequest = useRef(0);
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
@@ -162,6 +164,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const popoverRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const managerInputRef = useRef<HTMLInputElement>(null);
const addTagButtonRef = useRef<HTMLButtonElement>(null);
const tagTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
const restoreTagTriggerFocus = useRef(true);
const editingTagBaseline = useRef('');
@@ -212,7 +215,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
useEffect(() => {
const knownTagIds = new Set(tags.map(({ id }) => id));
setSelectedTagIds((current) => current.filter((id) => knownTagIds.has(id)));
if (!tagPopover) return;
if (!tagPopover || tagMutationPending.current || tagSavingId === tagPopover.deviceId) return;
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
const baselineChanged = !device || device.tagIds.length !== tagPopover.baseline.length
|| device.tagIds.some((id, index) => id !== tagPopover.baseline[index]);
@@ -220,19 +223,21 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (!baselineChanged && !catalogChanged) return;
closeTagPopover();
setTagAnnouncement('Список тегов изменился. Откройте теги устройства снова.');
}, [snapshot?.tags, snapshot?.devices]);
}, [snapshot?.tags, snapshot?.devices, tagSavingId, tagMutationCycle]);
useEffect(() => {
if (open) return;
restoreTagTriggerFocus.current = false;
setMobileRailOpen(false);
setRailMode('filters');
setAddingTag(false);
setEditingTagId('');
closeTagPopover(true, false);
}, [open]);
useEffect(() => {
if (railMode !== 'manager' || tagPopover || (compactRail && !mobileRailOpen)) return undefined;
const frame = requestAnimationFrame(() => managerInputRef.current?.focus());
const frame = requestAnimationFrame(() => (managerInputRef.current || addTagButtonRef.current)?.focus());
return () => cancelAnimationFrame(frame);
}, [compactRail, mobileRailOpen, railMode, tagPopover]);
@@ -285,7 +290,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (layoutRef.current) layoutRef.current.inert = true;
if (closeRef.current) closeRef.current.inert = true;
const frame = requestAnimationFrame(() => {
const assigned = popoverRef.current?.querySelector<HTMLInputElement>('input:checked');
const assigned = popoverRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
const first = popoverRef.current?.querySelector<HTMLElement>(FOCUSABLE);
(assigned || first)?.focus();
});
@@ -549,6 +554,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
function closeTagPopover(immediate = false, restoreFocus = true) {
if (!tagPopover) return;
if (tagMutationPending.current && !immediate) return;
restoreTagTriggerFocus.current = restoreFocus;
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
if (immediate || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
@@ -602,7 +608,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
});
}
function openTagPopover(device: Device, title: string, anchor: DOMRect) {
function openTagPopover(device: Device, anchor: DOMRect) {
if (tagMutationPending.current) return;
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
clearTagError();
setTagAnnouncement('');
@@ -610,7 +617,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
setTagPopoverClosing(false);
setTagPopover({
deviceId: device.id,
title,
baseline: [...device.tagIds],
draft: [...device.tagIds],
catalogKey: tags.map(({ id, name }) => `${id}:${name}`).join('|'),
@@ -618,11 +624,34 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
});
}
async function saveDeviceTags() {
if (!tagPopover) return;
async function toggleDeviceTag(tagId: string) {
if (!tagPopover || tagMutationPending.current) return;
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
if (!device) return;
if (await updateDeviceTags(device, tagPopover.draft, tagPopover.baseline)) closeTagPopover();
const baseline = [...tagPopover.baseline];
const selected = tagPopover.draft.includes(tagId);
const selectedIds = selected
? tagPopover.draft.filter((id) => id !== tagId)
: [...tagPopover.draft, tagId];
const next = tags.filter(({ id }) => selectedIds.includes(id)).map(({ id }) => id);
if (next.length > 8) return;
clearTagError();
setTagPopover((current) => current && ({ ...current, draft: next }));
tagMutationPending.current = true;
try {
if (await updateDeviceTags(device, next, baseline)) {
setTagPopover((current) => current?.deviceId === device.id
? { ...current, baseline: next, draft: next }
: current);
} else {
setTagPopover((current) => current?.deviceId === device.id
? { ...current, draft: baseline }
: current);
}
} finally {
tagMutationPending.current = false;
setTagMutationCycle((cycle) => cycle + 1);
}
}
function showTagManager() {
@@ -640,7 +669,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (!newTagName.trim() || !await createTag(newTagName)) return;
setTagErrorCopy('');
setNewTagName('');
requestAnimationFrame(() => managerInputRef.current?.focus());
setAddingTag(false);
requestAnimationFrame(() => addTagButtonRef.current?.focus());
}
async function submitTagRename(tag: DeviceTag) {
@@ -766,23 +796,12 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
onClick={() => {
clearTagError();
setTagErrorCopy('');
setAddingTag(false);
setEditingTagId('');
void changeRailMode('filters');
}}
>Назад</button>
<h3>Управление тегами</h3>
<form className="client-devices-tag-create" onSubmit={submitNewTag}>
<input
ref={managerInputRef}
value={newTagName}
maxLength={24}
placeholder="Название тега"
aria-label="Название нового тега"
disabled={tags.length >= 32 || tagSavingId === 'create'}
onChange={(event) => setNewTagName(event.target.value)}
/>
<button type="submit" disabled={tags.length >= 32 || tagSavingId === 'create' || !newTagName.trim()}>Создать</button>
</form>
{Boolean(tagError) && tagErrorCopy && <p className="client-devices-tag-error" role="alert">{tagErrorCopy}</p>}
<div className="client-devices-tag-manager-list">
{tags.map((tag) => <div key={tag.id} className="client-devices-tag-manager-row" data-tag-tone={tagTone(tag.id)}>
{editingTagId === tag.id ? <form onSubmit={(event) => {
@@ -802,18 +821,27 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</form> : <>
<span>{tag.name}</span><b>{counts.byTag[tag.id] || 0}</b>
<button
className={`client-devices-tag-edit${pencilAnimationId === `tag:${tag.id}` ? ' is-writing' : ''}`}
type="button"
aria-label={`Переименовать тег ${tag.name}`}
disabled={tagSavingId === tag.id}
onPointerEnter={() => setPencilAnimationId(`tag:${tag.id}`)}
onFocus={() => setPencilAnimationId(`tag:${tag.id}`)}
onClick={() => {
clearTagError();
setTagErrorCopy('');
setAddingTag(false);
setEditingTagId(tag.id);
setEditingTagName(tag.name);
editingTagBaseline.current = tag.name;
}}
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" /></svg></button>
><svg
viewBox="0 0 24 24"
aria-hidden="true"
onAnimationEnd={() => setPencilAnimationId((id) => id === `tag:${tag.id}` ? '' : id)}
><path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" /></svg></button>
<button
className="client-row-delete client-devices-tag-delete"
type="button"
aria-label={`Удалить тег ${tag.name}`}
disabled={tagSavingId === tag.id}
@@ -822,11 +850,46 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
setTagErrorCopy('');
setDeletingTagId(tag.id);
}}
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5" /></svg></button>
><svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-row-delete-lid" d="M8 7V5h8v2m-11 0h14" />
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
</svg></button>
</>}
</div>)}
{!tags.length && <p>Создайте первый тег, чтобы распределить устройства.</p>}
{!tags.length && !addingTag && <p>Тегов пока нет.</p>}
</div>
{addingTag ? <form className="client-devices-tag-create" onSubmit={submitNewTag}>
<input
ref={managerInputRef}
value={newTagName}
maxLength={24}
placeholder="Название тега"
aria-label="Название нового тега"
disabled={tags.length >= 32 || tagSavingId === 'create'}
onChange={(event) => setNewTagName(event.target.value)}
/>
<button type="submit" disabled={tags.length >= 32 || tagSavingId === 'create' || !newTagName.trim()}>Добавить</button>
<button type="button" disabled={tagSavingId === 'create'} onClick={() => {
clearTagError();
setTagErrorCopy('');
setNewTagName('');
setAddingTag(false);
requestAnimationFrame(() => addTagButtonRef.current?.focus());
}}>Отмена</button>
</form> : <button
ref={addTagButtonRef}
className="client-row-add client-devices-tag-add"
type="button"
disabled={tags.length >= 32}
onClick={() => {
clearTagError();
setTagErrorCopy('');
setEditingTagId('');
setAddingTag(true);
requestAnimationFrame(() => managerInputRef.current?.focus());
}}
>+ Добавить тег</button>}
{Boolean(tagError) && tagErrorCopy && <p className="client-devices-tag-error" role="alert">{tagErrorCopy}</p>}
</>}
</div>
</aside>
@@ -1093,8 +1156,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
data-tag-tone={firstTag ? tagTone(firstTag.id) : undefined}
type="button"
aria-label={`Изменить теги устройства ${title}`}
disabled={tagSavingId === device.id}
onClick={(event) => openTagPopover(device, title, event.currentTarget.getBoundingClientRect())}
disabled={Boolean(tagSavingId)}
onClick={(event) => openTagPopover(device, event.currentTarget.getBoundingClientRect())}
>
{firstTag ? <><span>{firstTag.name}</span>{deviceTags.length > 1 && <b>+{deviceTags.length - 1}</b>}</> : '+ тег'}
</button>}
@@ -1234,44 +1297,30 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
className="client-device-tag-popover"
role="dialog"
aria-modal="true"
aria-label={`Теги устройства ${tagPopover.title}`}
aria-label="Теги"
aria-busy={tagSavingId === tagPopover.deviceId}
style={{
top: `${Math.max(12, Math.min(tagPopover.anchor.bottom + 8, window.innerHeight - 360))}px`,
left: `${Math.max(12, Math.min(tagPopover.anchor.left, window.innerWidth - 292))}px`,
}}
>
<h3>Теги устройства {tagPopover.title}</h3>
{tags.length ? <div className="client-device-tag-popover-options">
{tags.map((tag) => {
const checked = tagPopover.draft.includes(tag.id);
return <label key={tag.id} data-tag-tone={tagTone(tag.id)}>
<input
type="checkbox"
checked={checked}
disabled={tagSavingId === tagPopover.deviceId || (!checked && tagPopover.draft.length >= 8)}
onChange={() => setTagPopover((current) => current && ({
...current,
draft: checked
? current.draft.filter((id) => id !== tag.id)
: [...current.draft, tag.id],
}))}
/>
<span>{tag.name}</span>
</label>;
const selected = tagPopover.draft.includes(tag.id);
return <button
key={tag.id}
data-tag-tone={tagTone(tag.id)}
type="button"
aria-pressed={selected}
aria-disabled={Boolean(tagSavingId) || (!selected && tagPopover.draft.length >= 8)}
onClick={() => {
if (tagSavingId || (!selected && tagPopover.draft.length >= 8)) return;
void toggleDeviceTag(tag.id);
}}
>{tag.name}</button>;
})}
</div> : <p>Тегов пока нет.</p>}
{Boolean(tagError) && <p className="client-devices-tag-error" role="alert">Не удалось сохранить теги.</p>}
<div className="client-device-tag-popover-actions">
{!tags.length && <button type="button" onClick={showTagManager}>Создать тег</button>}
<button type="button" disabled={tagSavingId === tagPopover.deviceId} onClick={() => closeTagPopover()}>Отмена</button>
<button
type="button"
disabled={tagSavingId === tagPopover.deviceId || (tagPopover.baseline.length === tagPopover.draft.length
&& tagPopover.baseline.every((id, index) => id === tagPopover.draft[index]))}
onClick={saveDeviceTags}
>Сохранить</button>
</div>
</div> : <button className="client-row-add" type="button" onClick={showTagManager}>+ Создать тег</button>}
{Boolean(tagError) && <p className="client-devices-tag-error" role="alert">Не удалось изменить тег.</p>}
</section>
</div>,
document.querySelector('.app.client-app') || document.body,