feat: distinguish remote operation and request activity

This commit is contained in:
vorotamoroz
2026-07-16 04:09:53 +00:00
parent 9f9758bc12
commit ba7ea27d0c
15 changed files with 579 additions and 91 deletions
+1 -1
Submodule src/lib updated: 4639e4537f...09bfafbd6c
+41 -41
View File
@@ -31,7 +31,12 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { $msg } from "@lib/common/i18n.ts";
import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts";
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
import {
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
import type { LiveSyncCore } from "@/main.ts";
import { LiveSyncError } from "@lib/common/LSError.ts";
import { isValidPath } from "@/common/utils.ts";
@@ -118,48 +123,43 @@ export class ModuleLog extends AbstractObsidianModule {
p2pLogCollector = new P2PLogCollector();
observeForLogs() {
const padSpaces = `\u{2007}`.repeat(10);
// const emptyMark = `\u{2003}`;
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) {
const formatted = reactiveSource("");
let timer: number | undefined = undefined;
let maxLen = 1;
numI.onChanged((numX) => {
const num = numX.value;
const numLen = `${Math.abs(num)}`.length + 1;
maxLen = maxLen < numLen ? numLen : maxLen;
if (timer) compatGlobal.clearTimeout(timer);
if (num == 0) {
timer = compatGlobal.setTimeout(() => {
formatted.value = "";
maxLen = 1;
}, 3000);
}
formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-maxLen)}`;
});
return computed(() => formatted.value);
}
const labelReplication = padLeftSpComputed(this.services.replication.replicationResultCount, `📥`);
const labelDBCount = padLeftSpComputed(this.services.replication.databaseQueueCount, `📄`);
const labelStorageCount = padLeftSpComputed(this.services.replication.storageApplyingCount, `💾`);
const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`);
const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`);
const labelConflictProcessCount = padLeftSpComputed(this.services.conflict.conflictProcessQueueCount, `🔩`);
const registerDisplay = <T extends { dispose(): void }>(display: T): T => {
this.plugin.register(() => display.dispose());
return display;
};
const labelReplication = registerDisplay(
createPaddedCounterLabel(this.services.replication.replicationResultCount, `📥`)
);
const labelDBCount = registerDisplay(
createPaddedCounterLabel(this.services.replication.databaseQueueCount, `📄`)
);
const labelStorageCount = registerDisplay(
createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
);
const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
const labelConflictProcessCount = registerDisplay(
createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
);
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value);
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`);
const labelHiddenFilesCount = registerDisplay(createPaddedCounterLabel(hiddenFilesCount, `⚙️`));
const queueCountLabelX = reactive(() => {
return `${labelReplication()}${labelDBCount()}${labelStorageCount()}${labelChunkCount()}${labelPluginScanCount()}${labelHiddenFilesCount()}${labelConflictProcessCount()}`;
return `${labelReplication.value}${labelDBCount.value}${labelStorageCount.value}${labelChunkCount.value}${labelPluginScanCount.value}${labelHiddenFilesCount.value}${labelConflictProcessCount.value}`;
});
const queueCountLabel = () => queueCountLabelX.value;
const trackedRequestCount = reactive(() => {
return getTrackedRequestCount(this.services.API.requestCount.value, this.services.API.responseCount.value);
});
const displayedTrackedRequestCount = registerDisplay(
createMinimumVisibleActivityCount(trackedRequestCount, REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS)
);
const requestingStatLabel = computed(() => {
return hasRemoteActivity(
this.services.API.requestCount.value,
this.services.API.responseCount.value,
this.services.replicator.boundedRemoteActivityCount.value
)
? "📲 "
: "";
return formatRemoteActivityStatusLabel({
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
trackedRequestCount: displayedTrackedRequestCount.value,
});
});
const replicationStatLabel = computed(() => {
@@ -215,11 +215,11 @@ export class ModuleLog extends AbstractObsidianModule {
}
return { w, sent, pushLast, arrived, pullLast };
});
const labelProc = padLeftSpComputed(this.services.fileProcessing.processing, ``);
const labelPend = padLeftSpComputed(this.services.fileProcessing.totalQueued, `🛫`);
const labelInBatchDelay = padLeftSpComputed(this.services.fileProcessing.batched, `📬`);
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, ``));
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
const waitingLabel = computed(() => {
return `${labelProc()}${labelPend()}${labelInBatchDelay()}`;
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
});
const statusLineLabel = computed(() => {
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
@@ -1,13 +0,0 @@
import { describe, expect, it } from "vitest";
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
describe("hasRemoteActivity", () => {
it("preserves the existing HTTP request balance signal", () => {
expect(hasRemoteActivity(2, 1, 0)).toBe(true);
expect(hasRemoteActivity(2, 2, 0)).toBe(false);
});
it("reports bounded remote activity without an HTTP request imbalance", () => {
expect(hasRemoteActivity(2, 2, 1)).toBe(true);
});
});
+26 -3
View File
@@ -1,4 +1,27 @@
/** Returns whether the status UI should report HTTP traffic or a finite remote operation in progress. */
export function hasRemoteActivity(requestCount: number, responseCount: number, boundedRemoteActivityCount: number) {
return requestCount - responseCount !== 0 || boundedRemoteActivityCount !== 0;
/** Status icon for a finite remote operation whose lifetime is known. */
export const REMOTE_OPERATION_ACTIVITY_ICON = "📲";
/** Status icon for approximate physical remote-request activity. */
export const REMOTE_REQUEST_ACTIVITY_ICON = "🌐";
/** Avoids hiding very short remote requests before the status bar can render them. */
export const REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS = 150;
export type RemoteActivityStatus = {
remoteOperationCount: number;
trackedRequestCount: number;
};
/** Returns the non-negative difference between tracked request starts and completions. */
export function getTrackedRequestCount(requestCount: number, responseCount: number): number {
return Math.max(0, requestCount - responseCount);
}
/** Formats the compact prefix shown before the replication status. */
export function formatRemoteActivityStatusLabel(status: RemoteActivityStatus): string {
const labels = [
status.remoteOperationCount > 0 ? REMOTE_OPERATION_ACTIVITY_ICON : "",
status.trackedRequestCount > 0 ? `${REMOTE_REQUEST_ACTIVITY_ICON}${status.trackedRequestCount}` : "",
].filter((label) => label !== "");
return labels.length > 0 ? `${labels.join(" ")} ` : "";
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
REMOTE_OPERATION_ACTIVITY_ICON,
REMOTE_REQUEST_ACTIVITY_ICON,
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
describe("getTrackedRequestCount", () => {
it("reports the non-negative difference between starts and completions", () => {
expect(getTrackedRequestCount(3, 2)).toBe(1);
expect(getTrackedRequestCount(2, 2)).toBe(0);
expect(getTrackedRequestCount(2, 3)).toBe(0);
});
});
describe("formatRemoteActivityStatusLabel", () => {
it("separates a finite remote operation from tracked physical requests", () => {
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 0 })).toBe(
`${REMOTE_OPERATION_ACTIVITY_ICON} `
);
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 1 })).toBe(
`${REMOTE_REQUEST_ACTIVITY_ICON}1 `
);
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 2 })).toBe(
`${REMOTE_OPERATION_ACTIVITY_ICON} ${REMOTE_REQUEST_ACTIVITY_ICON}2 `
);
});
it("omits inactive and invalid negative activity counts", () => {
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 0 })).toBe("");
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: -1, trackedRequestCount: -1 })).toBe("");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
export type DisposableReactiveValue<T> = ReactiveValue<T> & {
dispose(): void;
};
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
return {
get value() {
return value.value;
},
onChanged(handler) {
value.onChanged(handler);
},
offChanged(handler) {
value.offChanged(handler);
},
dispose,
};
}
/**
* Mirrors an activity count while keeping each visible period on screen for a
* minimum total lifetime. The delay applies only when the source becomes zero.
*/
export function createMinimumVisibleActivityCount(
source: ReactiveValue<number>,
minimumVisibleMs: number
): DisposableReactiveValue<number> {
const minimumLifetime = Math.max(0, minimumVisibleMs);
const displayed = reactiveSource(Math.max(0, source.value));
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
let hideTimer: number | undefined;
let disposed = false;
const cancelHide = () => {
if (hideTimer === undefined) return;
compatGlobal.clearTimeout(hideTimer);
hideTimer = undefined;
};
const hideIfIdle = () => {
hideTimer = undefined;
if (disposed || Math.max(0, source.value) > 0) return;
displayed.value = 0;
visibleSince = undefined;
};
const update = () => {
if (disposed) return;
const nextCount = Math.max(0, source.value);
cancelHide();
if (nextCount > 0) {
if (displayed.value === 0) {
visibleSince = Date.now();
}
displayed.value = nextCount;
return;
}
if (displayed.value === 0) {
visibleSince = undefined;
return;
}
const elapsed = Date.now() - (visibleSince ?? Date.now());
const remaining = Math.max(0, minimumLifetime - elapsed);
if (remaining === 0) {
hideIfIdle();
} else {
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
}
};
source.onChanged(update);
return asDisposableReactiveValue(displayed, () => {
if (disposed) return;
disposed = true;
cancelHide();
source.offChanged(update);
});
}
/**
* Formats a counter with a stable width and briefly retains its zero value so
* that the completion of queued work remains visible.
*/
export function createPaddedCounterLabel(
source: ReactiveValue<number>,
mark: string,
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
): DisposableReactiveValue<string> {
const linger = Math.max(0, inactiveLingerMs);
const formatted = reactiveSource("");
let maximumLength = 1;
let clearTimer: number | undefined;
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
};
const format = (count: number) => {
const requiredLength = `${Math.abs(count)}`.length + 1;
maximumLength = Math.max(maximumLength, requiredLength);
return ` ${mark}${`${STATUS_COUNTER_PADDING}${count}`.slice(-maximumLength)}`;
};
const update = () => {
if (disposed) return;
cancelClear();
const count = source.value;
formatted.value = format(count);
if (count !== 0) return;
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
formatted.value = "";
maximumLength = 1;
}, linger);
};
source.onChanged(update);
return asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
cancelClear();
source.offChanged(update);
});
}
@@ -0,0 +1,139 @@
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
STATUS_COUNTER_INACTIVE_LINGER_MS,
createMinimumVisibleActivityCount,
createPaddedCounterLabel,
} from "./StatusBarDisplay.ts";
describe("createMinimumVisibleActivityCount", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-16T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps a short activity visible for the configured minimum lifetime", () => {
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150);
const rendered = reactive(() => `active:${display.value}`);
expect(rendered.value).toBe("active:0");
source.value = 1;
expect(rendered.value).toBe("active:1");
vi.advanceTimersByTime(50);
source.value = 0;
expect(display.value).toBe(1);
vi.advanceTimersByTime(99);
expect(display.value).toBe(1);
vi.advanceTimersByTime(1);
expect(display.value).toBe(0);
expect(rendered.value).toBe("active:0");
display.dispose();
});
it("updates overlapping activity and starts a new minimum lifetime after becoming idle", () => {
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150);
source.value = 1;
vi.advanceTimersByTime(25);
source.value = 2;
expect(display.value).toBe(2);
source.value = 0;
vi.advanceTimersByTime(50);
source.value = 1;
expect(display.value).toBe(1);
vi.advanceTimersByTime(75);
source.value = 0;
expect(display.value).toBe(0);
source.value = 3;
source.value = 0;
expect(display.value).toBe(3);
vi.advanceTimersByTime(150);
expect(display.value).toBe(0);
display.dispose();
});
it("cancels pending work and stops observing its source when disposed", () => {
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150);
source.value = 1;
source.value = 0;
display.dispose();
vi.advanceTimersByTime(150);
source.value = 2;
expect(display.value).toBe(1);
});
});
describe("createPaddedCounterLabel", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps the widest counter label until its inactive linger period ends", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📥");
expect(display.value).toBe("");
source.value = 9;
expect(display.value).toBe(" 📥\u20079");
source.value = 123;
expect(display.value).toBe(" 📥\u2007123");
source.value = 0;
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
vi.advanceTimersByTime(1);
expect(display.value).toBe("");
source.value = 7;
expect(display.value).toBe(" 📥\u20077");
display.dispose();
});
it("cancels the pending clear when counter activity resumes", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📄");
source.value = 1;
source.value = 0;
vi.advanceTimersByTime(1_000);
source.value = 2;
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
expect(display.value).toBe(" 📄\u20072");
display.dispose();
});
it("cancels its inactive timer and source subscription when disposed", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📄");
source.value = 4;
source.value = 0;
display.dispose();
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
source.value = 5;
expect(display.value).toBe(" 📄\u20070");
});
});