Update Harbor client and gateway integration workflows
Build and Deploy Gateway / build-and-push (push) Failing after 14s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-19 18:16:10 +03:00
parent 416b2b294a
commit daec12e013
63 changed files with 5016 additions and 108 deletions
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react';
import { assertActivityJournalPage, type ActivityJournalEvent } from '../../../shared/activityJournal.js';
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
export function useActivityJournalFeature() {
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const close = (event: PointerEvent | KeyboardEvent) => {
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)
)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
return { isOpen, panelRef, toggleRef, closeRef, close: () => setIsOpen(false), toggle: () => setIsOpen((value) => !value) };
}
export type ActivityJournalFeature = ReturnType<typeof useActivityJournalFeature>;
export function ActivityJournalToggle({ feature, open, onToggle }: {
feature: ActivityJournalFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction
buttonRef={feature.toggleRef}
className="client-journal-toggle"
open={open}
controls="client-activity-journal"
ariaLabel={open ? 'Закрыть журнал событий' : 'Открыть журнал событий'}
label="Журнал"
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 8V4m0 4h4M5.6 7.1A8 8 0 1 1 4 12M12 7.5V12l3 2" />
<circle cx="12" cy="12" r=".8" />
</svg>
</RailAction>;
}
function eventCopy(event: ActivityJournalEvent) {
const value = event.data;
const copies: Record<string, [string, string]> = {
'connection.started': ['VPN включён', [value.profileLabel, value.serverLabel].filter(Boolean).join(' · ')],
'connection.stopped': ['VPN выключен', 'Остановлен пользователем'],
'connection.failed': ['VPN не запущен', String(value.errorCode || '')],
'subscription.added': ['Подписка добавлена', `${value.profileLabel || ''} · серверов: ${value.serverCount || 0}`],
'subscription.refreshed': ['Подписка обновлена', `${value.profileLabel || ''} · серверов: ${value.serverCount || 0} · +${value.added || 0} / ${value.removed || 0}`],
'subscription.refresh_failed': ['Подписка не обновлена', `${value.profileLabel || ''} · ${value.errorCode || ''}`],
'subscription.deleted': ['Подписка удалена', String(value.profileLabel || '')],
'failover.enabled': ['Резервный канал включён', 'Мониторинг начнётся после активации dual-config'],
'failover.disabled': ['Резервный канал выключен', 'Автоматика полностью остановлена'],
'failover.paused': ['Автопереключение на паузе', 'Проверки продолжаются'],
'failover.resumed': ['Автопереключение возобновлено', ''],
'failover.waiting_for_idle': ['Переключение отложено', 'Обнаружен активный трафик'],
'failover.switched': ['Новые соединения переключены', `${value.fromRole || ''}${value.toRole || ''} · ${value.reason || ''}`],
'failover.switch_failed': ['Переключение не выполнено', String(value.errorCode || '')],
'failover.both_unhealthy': ['Оба канала недоступны', 'Текущий маршрут сохранён'],
'failover.recovered': ['Основной канал восстановлен', String(value.reason || '')],
'journal.recovered': ['Журнал восстановлен', 'Повреждённый файл сохранён отдельно'],
};
return copies[event.type] || ['Системное событие', ''];
}
function dayLabel(value: string) {
const date = new Date(value);
const today = new Date();
const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const start = startDate.getTime();
const yesterday = new Date(startDate);
yesterday.setDate(yesterday.getDate() - 1);
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
if (day === start) return 'Сегодня';
if (day === yesterday.getTime()) return 'Вчера';
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(date);
}
export function ActivityJournalPanel({ feature, loadPage }: {
feature: ActivityJournalFeature;
loadPage: (cursor?: string | null) => Promise<unknown>;
}) {
const [events, setEvents] = useState<ActivityJournalEvent[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'refreshing' | 'older' | 'error'>('idle');
const [announcement, setAnnouncement] = useState('');
async function load(cursor: string | null = null, refresh = false) {
setStatus(cursor ? 'older' : refresh ? 'refreshing' : 'loading');
try {
const page = assertActivityJournalPage(await loadPage(cursor));
if (page.storage.status === 'error') throw new Error('journal unavailable');
if (refresh) {
const known = new Set(events.map(({ id }) => id));
setAnnouncement(`Журнал обновлён, новых событий: ${page.events.filter(({ id }) => !known.has(id)).length}`);
}
setEvents((current) => cursor ? [...current, ...page.events] : page.events);
setNextCursor(page.nextCursor);
setStatus('ready');
} catch {
setStatus('error');
}
}
useEffect(() => {
if (feature.isOpen) void load(null, status !== 'idle');
}, [feature.isOpen]);
const groups = events.reduce<Array<{ label: string; events: ActivityJournalEvent[] }>>((result, event) => {
const label = dayLabel(event.occurredAt);
const group = result.at(-1);
if (group?.label === label) group.events.push(event);
else result.push({ label, events: [event] });
return result;
}, []);
return <Drawer
panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-activity-journal"
open={feature.isOpen}
label="Журнал Harbor"
closeLabel="Закрыть журнал"
onClose={feature.close}
className="client-journal-drawer"
>
<header className="client-journal-header">
<span>Важные события хранятся 30 дней</span>
<div><h2>Журнал</h2><button type="button" aria-label="Обновить журнал" disabled={status === 'refreshing'} onClick={() => void load(null, true)}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" /></svg>
</button></div>
</header>
<span className="client-live-region" role="status" aria-live="polite">{announcement}</span>
{status === 'error' && <div className="client-journal-error" role="status">Журнал временно недоступен <button type="button" onClick={() => void load()}>Повторить</button></div>}
{status === 'loading' && !events.length ? <div className="client-journal-skeleton" aria-label="Загружаем журнал">{[0, 1, 2, 3].map((value) => <span key={value} />)}</div>
: !events.length && status === 'ready' ? <p className="client-journal-empty">За последние 30 дней важных событий пока нет</p>
: <div className="client-journal-groups">{groups.map((group) => <section key={group.label}>
<h3>{group.label}</h3>
<ol>{group.events.map((event) => {
const [title, details] = eventCopy(event);
return <li key={event.id} className={event.severity === 'error' ? 'is-error' : event.severity === 'warning' ? 'is-warning' : ''}>
<div className="client-journal-time"><time dateTime={event.occurredAt}>{new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(event.occurredAt))}</time><span>{event.source}</span></div>
<div><strong>{title}</strong>{details && <span>{details}</span>}{event.severity !== 'info' && <em>{event.severity === 'error' ? 'Ошибка' : 'Внимание'}</em>}</div>
</li>;
})}</ol>
</section>)}</div>}
<footer className="client-journal-footer">
{nextCursor ? <button type="button" disabled={status === 'older'} onClick={() => void load(nextCursor)}>{status === 'older' ? 'Загружаем…' : 'Показать ещё'}</button> : <span>{events.length ? 'Это вся история за последние 30 дней' : 'Храним события 30 дней'}</span>}
</footer>
</Drawer>;
}