diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 57f3f34..8d19af4 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.30.4', - gatewayClient: '0.31.4', + macClient: '0.30.5', + gatewayClient: '0.31.5', gatewayBackend: '0.31.0', }); diff --git a/src/web/features/activity-journal/ActivityJournalFeature.tsx b/src/web/features/activity-journal/ActivityJournalFeature.tsx index fc6137e..22f367c 100644 --- a/src/web/features/activity-journal/ActivityJournalFeature.tsx +++ b/src/web/features/activity-journal/ActivityJournalFeature.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { assertActivityJournalPage, type ActivityJournalEvent } from '../../../shared/activityJournal.js'; +import { compactActivityJournalEvents, refreshStreakCopy, type ActivityJournalDisplayItem } from './activityJournalModel.js'; import { Drawer } from '../../ui/Drawer.js'; import { RailAction } from '../../ui/RailAction.js'; @@ -122,11 +123,11 @@ export function ActivityJournalPanel({ feature, loadPage }: { if (feature.isOpen) void load(null, status !== 'idle'); }, [feature.isOpen]); - const groups = events.reduce>((result, event) => { - const label = dayLabel(event.occurredAt); + const groups = compactActivityJournalEvents(events).reduce>((result, item) => { + const label = dayLabel(item.event.occurredAt); const group = result.at(-1); - if (group?.label === label) group.events.push(event); - else result.push({ label, events: [event] }); + if (group?.label === label) group.items.push(item); + else result.push({ label, items: [item] }); return result; }, []); @@ -152,9 +153,18 @@ export function ActivityJournalPanel({ feature, loadPage }: { : !events.length && status === 'ready' ?

За последние 30 дней важных событий пока нет

:
{groups.map((group) =>

{group.label}

-
    {group.events.map((event) => { - const [title, details] = eventCopy(event); - return
  1. +
      {group.items.map((item) => { + const { event } = item; + const [title, details] = refreshStreakCopy(item) || eventCopy(event); + return
    1. {event.source}
      {title}{details && {details}}{event.severity !== 'info' && {event.severity === 'error' ? 'Ошибка' : 'Внимание'}}
    2. ; diff --git a/src/web/features/activity-journal/activityJournalModel.ts b/src/web/features/activity-journal/activityJournalModel.ts new file mode 100644 index 0000000..b95342a --- /dev/null +++ b/src/web/features/activity-journal/activityJournalModel.ts @@ -0,0 +1,89 @@ +import type { ActivityJournalEvent } from '../../../shared/activityJournal.js'; + +export interface ActivityJournalRefreshStreak { + count: number; + firstOccurredAt: string; + profileLabels: string[]; +} + +export interface ActivityJournalDisplayItem { + event: ActivityJournalEvent; + refreshStreak: ActivityJournalRefreshStreak | null; +} + +function localDay(value: string) { + const date = new Date(value); + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function isRoutineRefresh(event: ActivityJournalEvent) { + return event.type === 'subscription.refreshed' + && event.severity === 'info' + && Number(event.data.added || 0) === 0 + && Number(event.data.removed || 0) === 0; +} + +export function compactActivityJournalEvents(events: ActivityJournalEvent[]) { + const items: ActivityJournalDisplayItem[] = []; + for (let index = 0; index < events.length;) { + const event = events[index]; + if (!isRoutineRefresh(event)) { + items.push({ event, refreshStreak: null }); + index += 1; + continue; + } + + const day = localDay(event.occurredAt); + let end = index + 1; + while (end < events.length && isRoutineRefresh(events[end]) && localDay(events[end].occurredAt) === day) { + end += 1; + } + const streakEvents = events.slice(index, end); + if (streakEvents.length === 1) { + items.push({ event, refreshStreak: null }); + } else { + const profileLabels = [...new Set(streakEvents.map(({ data }) => String(data.profileLabel || 'Подписка')))]; + items.push({ + event, + refreshStreak: { + count: streakEvents.length, + firstOccurredAt: streakEvents.at(-1)?.occurredAt || event.occurredAt, + profileLabels, + }, + }); + } + index = end; + } + return items; +} + +function plural(value: number, one: string, few: string, many: string) { + const tens = value % 100; + const units = value % 10; + if (tens < 11 || tens > 14) { + if (units === 1) return one; + if (units >= 2 && units <= 4) return few; + } + return many; +} + +function formatObservedDuration(firstOccurredAt: string, lastOccurredAt: string) { + const minutes = Math.max(0, Math.round((Date.parse(lastOccurredAt) - Date.parse(firstOccurredAt)) / 60_000)); + if (minutes < 1) return 'меньше минуты'; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return [hours ? `${hours} ч` : '', remainingMinutes ? `${remainingMinutes} мин` : ''].filter(Boolean).join(' '); +} + +export function refreshStreakCopy(item: ActivityJournalDisplayItem): [string, string] | null { + const streak = item.refreshStreak; + if (!streak) return null; + const profile = streak.profileLabels.length === 1 + ? streak.profileLabels[0] + : `подписок: ${streak.profileLabels.length}`; + const updates = `${streak.count} ${plural(streak.count, 'обновление', 'обновления', 'обновлений')}`; + return [ + 'Подписки обновлялись без ошибок', + `${formatObservedDuration(streak.firstOccurredAt, item.event.occurredAt)} всё хорошо · ${updates} · ${profile}`, + ]; +} diff --git a/src/web/styles/features/activity-journal.css b/src/web/styles/features/activity-journal.css index e24a8fd..dd4e1d7 100644 --- a/src/web/styles/features/activity-journal.css +++ b/src/web/styles/features/activity-journal.css @@ -10,13 +10,23 @@ .client-journal-groups section { display: grid; gap: 7px; } .client-journal-groups h3 { margin: 0; } .client-journal-groups ol { display: grid; margin: 0; padding: 0; } -.client-journal-groups li { min-height: 58px; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 14px; align-items: start; padding: 10px 0; border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); } +.client-journal-groups li { position: relative; min-height: 58px; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 14px; align-items: start; padding: 10px 0; border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); } .client-journal-time, .client-journal-groups li > div:last-child { min-width: 0; display: grid; gap: 3px; } .client-journal-time time { color: var(--client-text); font: var(--type-data); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-data-tracking); text-transform: var(--type-data-transform); } .client-journal-time span, .client-journal-groups li span { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); overflow-wrap: anywhere; } .client-journal-time span { text-transform: var(--type-label-transform); } .client-journal-groups li strong { font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); } .client-journal-groups li em { width: fit-content; color: oklch(0.68 0.15 28); font: var(--type-label); font-style: normal; letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); } +.client-journal-event.is-switch-primary::before, +.client-journal-event.is-switch-reserve::before, +.client-journal-event.is-warning::before, +.client-journal-event.is-error::before { content: ''; position: absolute; inset: 9px auto 9px -8px; width: 3px; border-radius: 999px; } +.client-journal-event.is-switch-primary::before { background: var(--harbor-connect); } +.client-journal-event.is-switch-reserve::before { background: var(--harbor-gateway); } +.client-journal-event.is-warning::before { background: oklch(0.68 0.14 72); } +.client-journal-event.is-error::before { background: oklch(0.68 0.15 28); } +.client-journal-event.is-warning em { color: oklch(0.68 0.14 72); } +.client-journal-event.is-error em { color: oklch(0.68 0.15 28); } .client-journal-error, .client-journal-empty { min-height: 80px; margin: 0 8px; color: var(--client-muted); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); } .client-journal-error button, .client-journal-footer button { border: 0; background: transparent; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; } .client-journal-skeleton { display: grid; gap: 14px; margin: 0 8px; } diff --git a/test/web/activity-journal-feature-contract.test.js b/test/web/activity-journal-feature-contract.test.js index b38d76c..33decdc 100644 --- a/test/web/activity-journal-feature-contract.test.js +++ b/test/web/activity-journal-feature-contract.test.js @@ -7,6 +7,7 @@ const root = path.resolve(import.meta.dirname, '../..'); const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8'); const feature = fs.readFileSync(path.join(root, 'src/web/features/activity-journal/ActivityJournalFeature.tsx'), 'utf8'); const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/activity-journal.css'), 'utf8'); +const model = fs.readFileSync(path.join(root, 'src/web/features/activity-journal/activityJournalModel.ts'), 'utf8'); test('journal is the last Gateway-only drawer and refreshes whenever it opens', () => { assert.match(page, / { + const items = compactActivityJournalEvents([ + event('newest', '2026-08-25T15:00:00.000Z', 'subscription.refreshed', { profileLabel: 'Дом' }), + event('middle', '2026-08-25T13:30:00.000Z', 'subscription.refreshed', { profileLabel: 'Работа' }), + event('oldest', '2026-08-25T12:00:00.000Z', 'subscription.refreshed', { profileLabel: 'Дом' }), + ]); + + assert.equal(items.length, 1); + assert.deepEqual(items[0].refreshStreak, { + count: 3, + firstOccurredAt: '2026-08-25T12:00:00.000Z', + profileLabels: ['Дом', 'Работа'], + }); + assert.deepEqual(refreshStreakCopy(items[0]), [ + 'Подписки обновлялись без ошибок', + '3 ч всё хорошо · 3 обновления · подписок: 2', + ]); +}); + +test('another event and a changed server list break refresh streaks', () => { + const items = compactActivityJournalEvents([ + event('routine-new', '2026-08-25T15:00:00.000Z'), + event('started', '2026-08-25T14:50:00.000Z', 'connection.started', { profileLabel: 'Дом', serverLabel: 'Амстердам' }), + event('changed', '2026-08-25T14:45:00.000Z', 'subscription.refreshed', { added: 1 }), + event('routine-old', '2026-08-25T14:30:00.000Z'), + ]); + + assert.equal(items.length, 4); + assert.ok(items.every(({ refreshStreak }) => refreshStreak === null)); +}); + +test('a local calendar-day boundary breaks an otherwise adjacent streak', () => { + const newer = new Date(2026, 7, 25, 0, 5).toISOString(); + const older = new Date(2026, 7, 24, 23, 55).toISOString(); + const items = compactActivityJournalEvents([ + event('new-day', newer), + event('old-day', older), + ]); + + assert.equal(items.length, 2); + assert.ok(items.every(({ refreshStreak }) => refreshStreak === null)); +}); + +test('one-profile streak names the profile and keeps the latest event identity', () => { + const items = compactActivityJournalEvents([ + event('newest', '2026-08-25T15:15:00.000Z'), + event('oldest', '2026-08-25T15:00:00.000Z'), + ]); + + assert.equal(items[0].event.id, 'newest'); + assert.deepEqual(refreshStreakCopy(items[0]), [ + 'Подписки обновлялись без ошибок', + '15 мин всё хорошо · 2 обновления · Дом', + ]); +}); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index 6133191..1521c30 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -39,26 +39,26 @@ const expectedImports = [ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const acceptedLedger = { counts: { - cascadeEdges: 1037, + cascadeEdges: 1098, customProperties: 106, - declarations: 4110, + declarations: 4122, important: 0, keyframes: 50, media: 17, - rules: 1106, - variableReferences: 1049, + rules: 1113, + variableReferences: 1051, }, hashes: { - cascadeEdges: 'da134201db19eb45f82a1169f571fbea293ff8a76c6bcb24d3b10b060bba2fbd', + cascadeEdges: '9814b59202515f6993f2836d529b7bb9e25e632b0803c832a363ac264e5b60a3', customProperties: 'fd6f16069f9fe3526f09d4d574431ddae67150d8e7a8a40e04c9fd2d179ec7ac', - declarations: '89d33ebe8f3b462b0dcb3ae45bf2710d9a8633033047d025aa317b9e35d4d2b1', + declarations: 'dcbf716667469136ad6c25412fb24741c3b66749ceefb799d5785a6668dbf9de', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9', - ruleDeclarationSequences: '17a00d972ccc4ecb5450cfc5abd385602b6fd3911085e63c375d69df70bbda7f', - selectors: '28220ffd959c5f11f9956ebe90eb201c7fe3d4733b0ad6f756ecdc03f8512d13', - variableReferences: '09c3fc03be5d841b5276279ae40557f75ca79ef2c8b21f7578e67c8801f2a87c', - witnesses: 'ff6fe62b26aaae6c9171e176b9fd6176e13df145ce30998d83d52d696bb0ad8c', + ruleDeclarationSequences: '55167d84597ea8e8a4c4898f8fc8431593e920dc2e089728cc18c52f31d8c2cb', + selectors: '2ab1759842aadbf1e733e9fc97946f48578903bb47d192b0b373329262982299', + variableReferences: '2065fa19b325d995769988999b4e5dc51b54e8b011af39397f980584c3fffbe4', + witnesses: '2ade0023244f8fc6a2fed0b2e1c3a99c4055c32abd6d8bfcd0d5f117a48f05bb', }, }; @@ -408,8 +408,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); - assert.deepEqual(assets, ['index-CpsQVQ8c.css']); + assert.deepEqual(assets, ['index-BH_msVtT.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 156271); - assert.equal(sha256(built), '244fa71e9354cb1f81233cc630b8d5014c4f8ad910ad96dae4eee637d5c312e6'); + assert.equal(built.byteLength, 156918); + assert.equal(sha256(built), 'a1eafbf8c9c89a57478ba5c78d8b2a2fa1d44bcc8dc7bb747cba2889096509c5'); }); diff --git a/tsconfig.test.json b/tsconfig.test.json index ea81173..3101d7a 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -21,6 +21,7 @@ "src/web/features/subscription/requestError.ts", "src/web/features/servers/serverPickerModel.ts", "src/web/features/instructions/instructionBlocks.ts", + "src/web/features/activity-journal/activityJournalModel.ts", "src/web/features/instructions/prometheus.ts", "src/web/features/routing/ruleReorderModel.ts", "monitoring/grafana/harbor-gateway.json"