feat: show directional CouchDB comparison progress

This commit is contained in:
ARCJ137442 committed 2026-10-29 14:42:46 +08:00
1 parent a2eecce239
commit b3d74aa06d
2 files changed
+157 -67

No files matched your search

+77 -43
View File
@@ -13,13 +13,25 @@ import {
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000; export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined { export type FastForwardCheckedChanges = {
const count = source.value.couchDB?.pull?.checkedChanges; push?: number;
pull?: number;
};
function readPositiveCheckedChanges(
source: ReactiveValue<ReplicationStatics>,
direction: keyof FastForwardCheckedChanges
): number | undefined {
const count = source.value.couchDB?.[direction]?.checkedChanges;
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined; return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
} }
function copyCheckedChanges(counts: FastForwardCheckedChanges): FastForwardCheckedChanges {
return { ...counts };
}
/** /**
* Briefly displays changing CouchDB pull-feed comparison counts. * Briefly displays changing CouchDB push- and pull-feed comparison counts.
* *
* Unrelated replication-stat changes do not extend the linger or redisplay an * Unrelated replication-stat changes do not extend the linger or redisplay an
* expired count. A positive initial snapshot is displayed to late subscribers. * expired count. A positive initial snapshot is displayed to late subscribers.
@@ -27,46 +39,61 @@ function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>):
export function createFastForwardCheckedChangesDisplay( export function createFastForwardCheckedChangesDisplay(
source: ReactiveValue<ReplicationStatics>, source: ReactiveValue<ReplicationStatics>,
lingerMs = FAST_FORWARD_COUNT_LINGER_MS lingerMs = FAST_FORWARD_COUNT_LINGER_MS
): DisposableReactiveValue<number | undefined> { ): DisposableReactiveValue<FastForwardCheckedChanges> {
const displayed = reactiveSource<number | undefined>(undefined); const displayed = reactiveSource<FastForwardCheckedChanges>({});
const linger = Math.max(0, lingerMs); const linger = Math.max(0, lingerMs);
let lastObserved = readPositiveCheckedChanges(source); const directions: (keyof FastForwardCheckedChanges)[] = ["push", "pull"];
let clearTimer: number | undefined; const lastObserved: FastForwardCheckedChanges = {};
const clearTimers = new Map<keyof FastForwardCheckedChanges, number>();
let disposed = false; let disposed = false;
const cancelClear = () => { const cancelClear = (direction: keyof FastForwardCheckedChanges) => {
if (clearTimer === undefined) return; const timer = clearTimers.get(direction);
compatGlobal.clearTimeout(clearTimer); if (timer === undefined) return;
clearTimer = undefined; compatGlobal.clearTimeout(timer);
clearTimers.delete(direction);
}; };
const scheduleClear = () => { const scheduleClear = (direction: keyof FastForwardCheckedChanges) => {
clearTimer = compatGlobal.setTimeout(() => { const timer = compatGlobal.setTimeout(() => {
clearTimer = undefined; clearTimers.delete(direction);
if (disposed) return; if (disposed) return;
if (readPositiveCheckedChanges(source) !== lastObserved) { if (readPositiveCheckedChanges(source, direction) !== lastObserved[direction]) {
update(); update();
return; return;
} }
displayed.value = undefined; const nextDisplayed = copyCheckedChanges(displayed.value);
delete nextDisplayed[direction];
displayed.value = nextDisplayed;
}, linger); }, linger);
clearTimers.set(direction, timer);
}; };
const update = () => { const update = () => {
if (disposed) return; if (disposed) return;
const nextCount = readPositiveCheckedChanges(source); const nextDisplayed = copyCheckedChanges(displayed.value);
if (nextCount === lastObserved) return; let changed = false;
lastObserved = nextCount; for (const direction of directions) {
cancelClear(); const nextCount = readPositiveCheckedChanges(source, direction);
if (nextCount === undefined) { if (nextCount === lastObserved[direction]) continue;
displayed.value = undefined; lastObserved[direction] = nextCount;
return; cancelClear(direction);
if (nextCount === undefined) {
delete nextDisplayed[direction];
changed = true;
continue;
}
nextDisplayed[direction] = nextCount;
scheduleClear(direction);
changed = true;
} }
displayed.value = nextCount; if (changed) displayed.value = nextDisplayed;
scheduleClear();
}; };
if (lastObserved !== undefined) { for (const direction of directions) {
displayed.value = lastObserved; const count = readPositiveCheckedChanges(source, direction);
scheduleClear(); if (count === undefined) continue;
lastObserved[direction] = count;
displayed.value = { ...displayed.value, [direction]: count };
scheduleClear(direction);
} }
source.onChanged(update); source.onChanged(update);
@@ -83,7 +110,7 @@ export function createFastForwardCheckedChangesDisplay(
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
cancelClear(); for (const direction of directions) cancelClear(direction);
source.offChanged(update); source.offChanged(update);
}, },
}; };
@@ -113,7 +140,7 @@ function remainingSequenceLabel(
export function projectReplicationStatusLabel( export function projectReplicationStatusLabel(
stats: ReplicationStatics, stats: ReplicationStatics,
networkStatus: RemoteActivityStatus, networkStatus: RemoteActivityStatus,
fastForwardCount?: number fastForwardCounts?: FastForwardCheckedChanges
): ReplicationStatusProjection { ): ReplicationStatusProjection {
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats; const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
const networkActivity = formatRemoteActivityStatusLabel(networkStatus); const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
@@ -154,14 +181,19 @@ export function projectReplicationStatusLabel(
(couchDB.pull?.checking === true || couchDB.push?.checking === true); (couchDB.pull?.checking === true || couchDB.push?.checking === true);
if (isFastForwardChecking) icon = "⏩"; if (isFastForwardChecking) icon = "⏩";
const visibleFastForwardCount = const visibleFastForwardCounts: FastForwardCheckedChanges = {};
couchDB !== undefined && if (couchDB !== undefined) {
typeof fastForwardCount === "number" && for (const direction of ["push", "pull"] as const) {
Number.isFinite(fastForwardCount) && const count = fastForwardCounts?.[direction];
fastForwardCount > 0 if (typeof count === "number" && Number.isFinite(count) && count > 0) {
? fastForwardCount visibleFastForwardCounts[direction] = count;
: undefined; }
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")"; }
}
const fastForwardLabels = (["push", "pull"] as const)
.filter((direction) => visibleFastForwardCounts[direction] !== undefined)
.map((direction) => ` (⏩${direction === "push" ? "↑" : "↓"} ${visibleFastForwardCounts[direction]})`);
const fastForwardLabel = fastForwardLabels.join("");
const parts: StatusBarPart[] = []; const parts: StatusBarPart[] = [];
if (networkStatus.trackedRequestCount > 0) { if (networkStatus.trackedRequestCount > 0) {
@@ -177,13 +209,15 @@ export function projectReplicationStatusLabel(
parts.push({ key: "network", text: networkActivity }); parts.push({ key: "network", text: networkActivity });
} }
parts.push({ key: "sync", text: "Sync: " + icon }); parts.push({ key: "sync", text: "Sync: " + icon });
if (visibleFastForwardCount !== undefined) { for (const direction of ["push", "pull"] as const) {
const count = visibleFastForwardCounts[direction];
if (count === undefined) continue;
parts.push({ parts.push({
key: "ff", key: `ff-${direction}`,
text: " (⏩ ", text: ` (⏩${direction === "push" ? "↑" : "↓"} `,
value: visibleFastForwardCount, value: count,
suffix: ")", suffix: ")",
title: "Compared CouchDB feed entries", title: `Compared CouchDB ${direction} feed entries`,
}); });
} }
parts.push({ key: "sent", text: " ↑ ", value: sent }); parts.push({ key: "sent", text: " ↑ ", value: sent });
@@ -90,18 +90,22 @@ describe("projectReplicationStatusLabel", () => {
const idleWithCount = projectReplicationStatusLabel( const idleWithCount = projectReplicationStatusLabel(
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }), stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
noNetworkActivity, noNetworkActivity,
19 { push: 19, pull: 19 }
); );
expect(idleWithCount.icon).toBe(icon); expect(idleWithCount.icon).toBe(icon);
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true); expect(idleWithCount.parts.some((part) => part.key === "ff-push")).toBe(true);
expect(idleWithCount.parts.some((part) => part.key === "ff-pull")).toBe(true);
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19); const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, {
push: 19,
pull: 19,
});
expect(withoutCouchDB.icon).toBe(icon); expect(withoutCouchDB.icon).toBe(icon);
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false); expect(withoutCouchDB.parts.some((part) => part.key.startsWith("ff-"))).toBe(false);
} }
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity); const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false); expect(noFastForward.parts.some((part) => part.key.startsWith("ff-"))).toBe(false);
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0"); expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
}); });
@@ -123,18 +127,40 @@ describe("projectReplicationStatusLabel", () => {
couchDB: couchDBProgress("continuous", 1200, false), couchDB: couchDBProgress("continuous", 1200, false),
}), }),
noNetworkActivity, noNetworkActivity,
1200 { pull: 1200 }
); );
expect(transferred.icon).toBe("⚡"); expect(transferred.icon).toBe("⚡");
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7"); expect(transferred.message).toBe("Sync: ⚡ (⏩↓ 1200) ↑ 0 ↓ 7");
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]); expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff-pull", "sent", "arrived"]);
expect(transferred.parts[1]).toMatchObject({ expect(transferred.parts[1]).toMatchObject({
key: "ff", key: "ff-pull",
text: " (⏩ ", text: " (⏩↓ ",
value: 1200, value: 1200,
suffix: ")", suffix: ")",
title: "Compared CouchDB pull feed entries",
}); });
const pushChecking = projectReplicationStatusLabel(
stats({
couchDB: {
phase: "continuous",
push: { checkedChanges: 71975, completedBatches: 2878, checking: true },
},
}),
noNetworkActivity,
{ push: 71975 }
);
expect(pushChecking.icon).toBe("⏩");
expect(pushChecking.message).toBe("Sync: ⏩ (⏩↑ 71975) ↑ 0 ↓ 0");
expect(pushChecking.parts[1]).toMatchObject({ key: "ff-push", value: 71975 });
const bothChecking = projectReplicationStatusLabel(
stats({ couchDB: couchDBProgress("continuous", 10, true) }),
noNetworkActivity,
{ push: 71975, pull: 10 }
);
expect(bothChecking.message).toBe("Sync: ⏩ (⏩↑ 71975) (⏩↓ 10) ↑ 0 ↓ 0");
const idle = projectReplicationStatusLabel( const idle = projectReplicationStatusLabel(
stats({ stats({
syncStatus: "CONNECTED", syncStatus: "CONNECTED",
@@ -142,10 +168,10 @@ describe("projectReplicationStatusLabel", () => {
couchDB: couchDBProgress("idle", 1200, false), couchDB: couchDBProgress("idle", 1200, false),
}), }),
noNetworkActivity, noNetworkActivity,
1200 { pull: 1200 }
); );
expect(idle.icon).toBe("⚡"); expect(idle.icon).toBe("⚡");
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7"); expect(idle.message).toBe("Sync: ⚡ (⏩↓ 1200) ↑ 0 ↓ 7");
}); });
it("keeps the error icon ahead of an active CouchDB checking indicator", () => { it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
@@ -154,7 +180,7 @@ describe("projectReplicationStatusLabel", () => {
syncStatus: "ERRORED", syncStatus: "ERRORED",
couchDB: { couchDB: {
...couchDBProgress("one-shot", 2, true), ...couchDBProgress("one-shot", 2, true),
push: { checkedChanges: 0, completedBatches: 0, checking: true }, push: { checkedChanges: 2, completedBatches: 1, checking: true },
}, },
}), }),
noNetworkActivity noNetworkActivity
@@ -181,41 +207,41 @@ describe("createFastForwardCheckedChangesDisplay", () => {
); );
const display = createFastForwardCheckedChangesDisplay(source); const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBe(1200); expect(display.value).toEqual({ pull: 1200 });
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
source.value = stats({ source.value = stats({
sent: 3, sent: 3,
couchDB: couchDBProgress("idle", 1200, false), couchDB: couchDBProgress("idle", 1200, false),
}); });
expect(display.value).toBe(1200); expect(display.value).toEqual({ pull: 1200 });
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001); vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
expect(display.value).toBe(1200); expect(display.value).toEqual({ pull: 1200 });
source.value = stats({ source.value = stats({
sent: 4, sent: 4,
couchDB: couchDBProgress("idle", 1200, false), couchDB: couchDBProgress("idle", 1200, false),
}); });
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
expect(display.value).toBeUndefined(); expect(display.value).toEqual({});
source.value = stats({ source.value = stats({
syncStatus: "PAUSED", syncStatus: "PAUSED",
couchDB: couchDBProgress("continuous", 1200, true), couchDB: couchDBProgress("continuous", 1200, true),
}); });
expect(display.value).toBeUndefined(); expect(display.value).toEqual({});
source.value = stats({ source.value = stats({
couchDB: couchDBProgress("continuous", 1201, true), couchDB: couchDBProgress("continuous", 1201, true),
}); });
expect(display.value).toBe(1201); expect(display.value).toEqual({ pull: 1201 });
source.value = stats({ source.value = stats({
couchDB: couchDBProgress("one-shot", 0, true), couchDB: couchDBProgress("one-shot", 0, true),
}); });
expect(display.value).toBeUndefined(); expect(display.value).toEqual({});
source.value = stats(); source.value = stats();
expect(display.value).toBeUndefined(); expect(display.value).toEqual({});
display.dispose(); display.dispose();
}); });
@@ -227,16 +253,46 @@ describe("createFastForwardCheckedChangesDisplay", () => {
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS); vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) }); source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
expect(display.value).toBe(5); expect(display.value).toEqual({ pull: 5 });
}); });
it("does not display progress for providers without CouchDB replication state", () => { it("does not display progress for providers without CouchDB replication state", () => {
const source = reactiveSource(stats()); const source = reactiveSource(stats());
const display = createFastForwardCheckedChangesDisplay(source); const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBeUndefined(); expect(display.value).toEqual({});
source.value = stats({ couchDB: { phase: "continuous" } }); source.value = stats({ couchDB: { phase: "continuous" } });
expect(display.value).toBeUndefined(); expect(display.value).toEqual({});
display.dispose();
});
it("tracks push and pull independently and expires only the direction that stopped changing", () => {
const source = reactiveSource(
stats({
couchDB: {
phase: "catch-up",
pull: { checkedChanges: 4, completedBatches: 1, checking: true },
push: { checkedChanges: 40, completedBatches: 2, checking: true },
},
})
);
const display = createFastForwardCheckedChangesDisplay(source, 100);
expect(display.value).toEqual({ push: 40, pull: 4 });
vi.advanceTimersByTime(50);
source.value = stats({
couchDB: {
phase: "catch-up",
pull: { checkedChanges: 4, completedBatches: 1, checking: true },
push: { checkedChanges: 41, completedBatches: 2, checking: true },
},
});
expect(display.value).toEqual({ push: 41, pull: 4 });
vi.advanceTimersByTime(50);
expect(display.value).toEqual({ push: 41 });
vi.advanceTimersByTime(50);
expect(display.value).toEqual({});
display.dispose(); display.dispose();
}); });
}); });