perf(store): fast-path по ссылке, ограничение живущих всю сессию коллекций

- store.set выполнял JSON.stringify с сортировкой ключей над старым и новым
  значением каждого обновляемого ключа; ежесекундный ui-state poll дважды в
  секунду сериализовал весь payload секций даже без изменений. Дешёвая
  проверка по идентичности отсекает частый случай.
- Sets/Maps учёта задач (handled subscription/latency/component/service jobs,
  custom proxy latencies) росли всю сессию LuCI без границ; теперь они
  ограничены 500 записями через общий хелпер.
- AI-чат модалка: параллельные отправки перемешивали удаление плейсхолдера
  "печатает" и портили ветку диалога; поле ввода и кнопка блокируются на
  время запроса.
This commit is contained in:
Dushnilin
2026-08-21 20:41:32 +03:00
parent 5878e2dfd9
commit fc18082bf6
6 changed files with 57 additions and 1 deletions
@@ -0,0 +1,28 @@
// Session-lifetime job-tracking collections grow by one entry per backend
// job; on a long-lived LuCI tab nothing pruned them. Cap oldest entries away
// once a collection exceeds the limit - the backend cleans its own state, so
// old ids never reappear.
const DEFAULT_MAX_ENTRIES = 500;
export function capSetSize<T>(set: Set<T>, max = DEFAULT_MAX_ENTRIES): void {
while (set.size > max) {
const oldest = set.values().next().value;
if (oldest === undefined) {
return;
}
set.delete(oldest);
}
}
export function capMapSize<K, V>(
map: Map<K, V>,
max = DEFAULT_MAX_ENTRIES,
): void {
while (map.size > max) {
const oldest = map.keys().next().value;
if (oldest === undefined) {
return;
}
map.delete(oldest);
}
}
@@ -48,6 +48,11 @@ export class StoreService<T extends Record<string, any>> {
const diff: Partial<T> = {};
for (const key in next) {
// Reference equality fast path: most poll ticks pass the same object
// identity or genuinely new objects; the sorted stringify below runs
// on every key of a multi-hundred-KB payload twice per second
// otherwise.
if (next[key] === prev[key]) continue;
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
}
@@ -61,6 +61,7 @@ import { isActiveLuciTab } from '../../helpers/isActiveLuciTab';
import { isTransientRpcError } from '../../helpers/isTransientRpcError';
import { shouldShowLoadingForRestoredAction } from '../../helpers/restoredActionLoading';
import { getServiceAvailability } from '../../helpers/serviceAvailability';
import { capSetSize, capMapSize } from '../../helpers/capCollectionSize';
const SECTIONS_REFRESH_INTERVAL_MS = 10000;
const LATENCY_TEST_BUTTON_CLASS = 'dashboard-sections-grid-item-test-latency';
@@ -315,6 +316,7 @@ async function completeSubscriptionUpdateJob(
if (jobId) {
handledSubscriptionJobs.add(jobId);
capSetSize(handledSubscriptionJobs);
}
setSubscriptionUpdating(sectionName, false);
@@ -396,6 +398,7 @@ async function completeLatencyTestJob(jobId: string, sectionName: string) {
if (jobId) {
handledLatencyJobs.add(jobId);
capSetSize(handledLatencyJobs);
}
if (jobId) {
@@ -761,6 +764,7 @@ async function handleTestLatency(
} else {
customProxyLatencies.set(tag, -1);
}
capMapSize(customProxyLatencies);
setLatencyFetching(sectionName, false);
completed = true;
void fetchDashboardSections({ force: true });
@@ -5,6 +5,7 @@ import {
} from '../../../helpers';
import { showToast } from '../../../helpers/showToast';
import { notifyActionFailure } from '../../helpers/notifyActionFailure';
import { capSetSize } from '../../helpers/capCollectionSize';
import { runDnsCheck } from './checks/runDnsCheck';
import { runSingBoxCheck } from './checks/runSingBoxCheck';
import { runInboundsCheck } from './checks/runInboundsCheck';
@@ -322,6 +323,7 @@ async function followServiceActionState(state: Tachyon.ServiceActionState) {
logger.error('[DIAGNOSTIC]', 'followServiceActionState failed', error);
} finally {
handledServiceActionJobs.add(jobId);
capSetSize(handledServiceActionJobs);
setServiceActionStateLoading(state, false);
await refreshDiagnosticServicesInfo({ force: true, allowInactive: true });
void TachyonShellMethods.uiActionAck('service', jobId);
@@ -618,6 +620,8 @@ async function handleServiceRuntimeAction({
await refreshDiagnosticServicesInfo({ force: true, allowInactive: true });
if (jobId) {
handledServiceActionJobs.add(jobId);
capSetSize(handledServiceActionJobs);
capSetSize(handledServiceActionJobs);
void TachyonShellMethods.uiActionAck('service', jobId);
}
resetDiagnosticsChecks();
@@ -69,9 +69,16 @@ export function renderAiChatModal() {
onClick: () => handleSend(),
});
let sendInFlight = false;
const handleSend = async (queryText?: string) => {
const text = (queryText || chatInput.value).trim();
if (!text) return;
// A single request in flight: concurrent sends would interleave the
// "typing" placeholder removals and corrupt the thread.
if (!text || sendInFlight) return;
sendInFlight = true;
chatInput.disabled = true;
(sendBtn as HTMLButtonElement).disabled = true;
const userMsg: ChatMessage = {
sender: 'user',
@@ -146,6 +153,9 @@ export function renderAiChatModal() {
}
renderMessages();
sendInFlight = false;
chatInput.disabled = false;
(sendBtn as HTMLButtonElement).disabled = false;
};
chatInput.onkeydown = (e: KeyboardEvent) => {
@@ -1,6 +1,7 @@
import { onMount, preserveScrollForPage } from '../../../helpers';
import { TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT } from '../../../constants';
import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion';
import { capSetSize } from '../../helpers/capCollectionSize';
import { showToast } from '../../../helpers/showToast';
import {
renderDownloadIcon24,
@@ -127,6 +128,7 @@ try {
const savedJob = sessionStorage.getItem('tachyon_post_update_job');
if (savedJob) {
handledComponentJobs.add(savedJob);
capSetSize(handledComponentJobs);
followedComponentJobs.add(savedJob);
sessionStorage.removeItem('tachyon_post_update_job');
}
@@ -660,6 +662,7 @@ async function completeComponentActionJob(
}
handledComponentJobs.add(jobId);
capSetSize(handledComponentJobs);
setActionLoading(key, false);
if (shouldNotify) {
showToast(message, 'error');
@@ -670,6 +673,7 @@ async function completeComponentActionJob(
}
handledComponentJobs.add(jobId);
capSetSize(handledComponentJobs);
await ackComponentActionJob(jobId);
await applyCompletedComponentAction({
key,
@@ -797,6 +801,7 @@ function handleComponentUiState(uiState: Tachyon.UiState) {
void followComponentActionState(state);
} else {
handledComponentJobs.add(jobId);
capSetSize(handledComponentJobs);
void ackComponentActionJob(jobId);
}
}