Improve activity journal readability
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Array<{ label: string; events: ActivityJournalEvent[] }>>((result, event) => {
|
||||
const label = dayLabel(event.occurredAt);
|
||||
const groups = compactActivityJournalEvents(events).reduce<Array<{ label: string; items: ActivityJournalDisplayItem[] }>>((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' ? <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' : ''}>
|
||||
<ol>{group.items.map((item) => {
|
||||
const { event } = item;
|
||||
const [title, details] = refreshStreakCopy(item) || eventCopy(event);
|
||||
return <li key={event.id} className={event.severity === 'error'
|
||||
? 'client-journal-event is-error'
|
||||
: event.severity === 'warning'
|
||||
? 'client-journal-event is-warning'
|
||||
: event.type === 'failover.switched' && event.data.toRole === 'primary'
|
||||
? 'client-journal-event is-switch-primary'
|
||||
: event.type === 'failover.switched' && event.data.toRole === 'reserve'
|
||||
? 'client-journal-event is-switch-reserve'
|
||||
: 'client-journal-event'}>
|
||||
<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>;
|
||||
|
||||
@@ -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}`,
|
||||
];
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user