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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user