mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 05:02:16 +01:00
922 lines
34 KiB
TypeScript
922 lines
34 KiB
TypeScript
import type {
|
|
Flow,
|
|
FlowSortKey,
|
|
GetProxyStateResponse,
|
|
ModelPayload,
|
|
SavedQuery,
|
|
SortValue,
|
|
} from "@yaakapp-internal/proxy-lib";
|
|
import { selectedIdsFamily } from "@yaakapp-internal/ui";
|
|
import { atom, type createStore } from "jotai";
|
|
import { atomWithStorage } from "jotai/utils";
|
|
import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
|
|
import {
|
|
type ColumnPrefs,
|
|
defaultColumnPrefs,
|
|
parseColumnPrefs,
|
|
sortComparator,
|
|
sortFlows,
|
|
} from "./columns";
|
|
import { appendPage, applySortedChanges, pageBoundary, type SortedWindow } from "./sortedWindow";
|
|
import {
|
|
applyChanges,
|
|
emptyWindow,
|
|
type FlowWindow,
|
|
PAGE_SIZE,
|
|
prependOlder,
|
|
trimWindow,
|
|
WINDOW_SIZE,
|
|
windowFromPage,
|
|
} from "./flowWindow";
|
|
import { activeQuery, anyCountsBackFromNow, countsBackFromNow, type FlowQuery } from "./query";
|
|
import { listen, rpc } from "./rpc";
|
|
import { changesFor, FlowEvents } from "./searchChanges";
|
|
import {
|
|
ALL_ID,
|
|
APPS_ID,
|
|
buildSidebarTree,
|
|
DOMAINS_ID,
|
|
itemsById,
|
|
queryInText,
|
|
SAVED_ID,
|
|
type SavedRow,
|
|
selectionText,
|
|
} from "./sidebarTree";
|
|
|
|
type Store = ReturnType<typeof createStore>;
|
|
|
|
/** How long new flows gather before they reach the list, so a burst renders once */
|
|
const FLUSH_MS = 100;
|
|
/** How long after new flows the total is counted again from the database */
|
|
const RECOUNT_MS = 1000;
|
|
/** How often the database is read for new flows while another process owns the engine */
|
|
const POLL_MS = 1000;
|
|
/** How often a search counting back from now is read again, as flows age in and out of it */
|
|
const AGE_REFRESH_MS = 15_000;
|
|
/** How often the engine's owner is checked while it is another process */
|
|
const OWNER_POLL_MS = 2000;
|
|
|
|
/** The flows in memory: a window onto the database, which keeps them all */
|
|
export const flowWindowAtom = atom<FlowWindow>(emptyWindow);
|
|
|
|
/** Oldest first, so new traffic arrives at the end of the list */
|
|
export const flowsAtom = atom((get) => get(flowWindowAtom).flows);
|
|
|
|
/** Start of the newest flow held, across all traffic */
|
|
export const newestStartedAtAtom = atom((get) => get(flowsAtom).at(-1)?.startedAt ?? null);
|
|
|
|
/** Flows in the database */
|
|
export const flowTotalAtom = atom((get) => get(flowWindowAtom).total);
|
|
|
|
/** The first page of flows has loaded, so an empty window means an empty database */
|
|
export const flowsLoadedAtom = atom(false);
|
|
|
|
/**
|
|
* The database has no flows and nothing narrows the list. A boolean of its own, so new traffic
|
|
* does not re-render what reads it.
|
|
*/
|
|
export const noFlowsAtom = atom(
|
|
(get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(searchTextAtom).trim() === "",
|
|
);
|
|
|
|
/** The system proxy has been on at some point since this install, which first run waits for */
|
|
export const systemProxyEverOnAtom = atomWithStorage("system_proxy_ever_on", false, undefined, {
|
|
getOnInit: true,
|
|
});
|
|
|
|
/** Some flow held names the app that sent it, which only some platforms can tell */
|
|
export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null));
|
|
|
|
/** The search box's text as typed, which the sidebar's selection writes too */
|
|
export const searchTextAtom = atom("");
|
|
|
|
/** Bumped when the text changes from outside the box, such as the sidebar, so the box takes it */
|
|
export const searchTextVersionAtom = atom(0);
|
|
|
|
/** The search box's text once typing settles */
|
|
export const searchTermsAtom = atom("");
|
|
|
|
/** System's flows are left out of All traffic, by the eye on System's row */
|
|
export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, undefined, {
|
|
getOnInit: true,
|
|
});
|
|
|
|
/** The search the list shows, from the search box's settled text, or null for all traffic */
|
|
export const activeQueryAtom = atom<FlowQuery | null>((get) =>
|
|
activeQuery(get(searchTermsAtom), get(hideSystemAtom)),
|
|
);
|
|
|
|
/** The saved searches, in their order */
|
|
export const savedQueriesAtom = atom<SavedQuery[]>([]);
|
|
|
|
/** How many flows each saved search covers, by id, counted in the database */
|
|
export const savedCountsAtom = atom<Record<string, number>>({});
|
|
|
|
/** The saved searches as the sidebar lists them */
|
|
const savedRowsAtom = atom<SavedRow[]>((get) => {
|
|
const counts = get(savedCountsAtom);
|
|
return get(savedQueriesAtom).map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
query: s.query,
|
|
count: counts[s.id] ?? 0,
|
|
}));
|
|
});
|
|
|
|
/** Writes `text` into the search box at once, as the sidebar does, without waiting for typing */
|
|
export const setSearchTextAtom = atom(null, (_get, set, text: string) => {
|
|
set(searchTextAtom, text);
|
|
set(searchTermsAtom, text);
|
|
set(searchTextVersionAtom, (v) => v + 1);
|
|
});
|
|
|
|
/** Flows All traffic covers in the database: every one, or all but System's while hidden */
|
|
export const allTrafficTotalAtom = atom((get) =>
|
|
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
|
|
);
|
|
|
|
/** Flows in the database not from System, counted while System is hidden */
|
|
export const nonSystemTotalAtom = atom(0);
|
|
|
|
/**
|
|
* The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic
|
|
* rebuilds its trees once rather than on every batch
|
|
*/
|
|
export const sidebarFlowsAtom = atom<Flow[]>([]);
|
|
|
|
/** How long the sidebar's counts may lag the list */
|
|
const SIDEBAR_MS = 1000;
|
|
|
|
/** The sidebar's rows, from the flows loaded */
|
|
export const sidebarTreeAtom = atom((get) =>
|
|
buildSidebarTree(get(sidebarFlowsAtom), get(allTrafficTotalAtom), get(savedRowsAtom)),
|
|
);
|
|
|
|
const storedColumnPrefsAtom = atomWithStorage<unknown>(
|
|
"flow_columns",
|
|
defaultColumnPrefs,
|
|
undefined,
|
|
{
|
|
getOnInit: true,
|
|
},
|
|
);
|
|
|
|
/** Which columns the list shows, how wide, and its sort, as the person left them */
|
|
export const columnPrefsAtom = atom(
|
|
(get) => parseColumnPrefs(get(storedColumnPrefsAtom)),
|
|
(get, set, update: ColumnPrefs | ((prev: ColumnPrefs) => ColumnPrefs)) => {
|
|
const prev = parseColumnPrefs(get(storedColumnPrefsAtom));
|
|
set(storedColumnPrefsAtom, typeof update === "function" ? update(prev) : update);
|
|
},
|
|
);
|
|
|
|
/** The list's sort keys, in order, kept with the columns; empty for the order flows arrive in */
|
|
export const flowSortAtom = atom(
|
|
(get) => get(columnPrefsAtom).sort,
|
|
(_get, set, sort: FlowSortKey[]) => set(columnPrefsAtom, (p) => ({ ...p, sort })),
|
|
);
|
|
|
|
/** The flows the list shows while sorted, read from the database in the sort's order */
|
|
export const sortedWindowAtom = atom<SortedWindow | null>(null);
|
|
|
|
/** A window onto the flows a search covers, loaded from the database as the main one is */
|
|
export interface SearchWindow {
|
|
key: string;
|
|
query: FlowQuery;
|
|
win: FlowWindow;
|
|
/** The first page has not arrived yet */
|
|
loading: boolean;
|
|
/** Changes that came while the first page loaded, applied once it is in */
|
|
buffered: ModelPayload[];
|
|
}
|
|
|
|
export const searchWindowAtom = atom<SearchWindow | null>(null);
|
|
|
|
/** The window the list shows: the search's, or all traffic's */
|
|
const shownWindowAtom = atom((get) => get(searchWindowAtom)?.win ?? get(flowWindowAtom));
|
|
|
|
/** The flows loaded for the list, oldest first */
|
|
export const windowFlowsAtom = atom((get) => get(shownWindowAtom).flows);
|
|
|
|
/**
|
|
* The flows the list shows: oldest first, or in the sort's order from the database. Until a
|
|
* sort's first page is in, the flows loaded, sorted here, stand in for it.
|
|
*/
|
|
export const shownFlowsAtom = atom((get) => {
|
|
const sort = get(flowSortAtom);
|
|
if (sort.length === 0) return get(windowFlowsAtom);
|
|
const sorted = get(sortedWindowAtom);
|
|
if (sorted == null || (sorted.loading && sorted.flows.length === 0)) {
|
|
return sortFlows(get(windowFlowsAtom), sort);
|
|
}
|
|
return sorted.flows;
|
|
});
|
|
|
|
/** Flows in the database the list covers: every one, or those the search matches */
|
|
export const shownTotalAtom = atom((get) => get(shownWindowAtom).total);
|
|
|
|
/** The search's first page has not arrived, so the list shows none of its flows yet */
|
|
export const searchLoadingAtom = atom((get) => get(searchWindowAtom)?.loading ?? false);
|
|
|
|
/** Start and id of the oldest flow shown, which loading older flows moves back */
|
|
export const oldestFlowAtom = atom((get) => get(windowFlowsAtom)[0] ?? null);
|
|
|
|
/** Follows new traffic, dropping the older flows scrolling up loaded, or holds the list */
|
|
export const followAtom = atom(
|
|
(get) => get(followingAtom),
|
|
(_get, set, following: boolean) => {
|
|
set(followingAtom, following);
|
|
if (!following) return;
|
|
set(flowWindowAtom, (win) => trimWindow(win));
|
|
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: trimWindow(s.win) }));
|
|
},
|
|
);
|
|
|
|
/** The engine's state, kept current from its events, and polled while another process has it */
|
|
export const proxyStateAtom = atom<GetProxyStateResponse | null>(null);
|
|
|
|
/** Why captured traffic can't be read, while it can't: nothing that reads it is offered then */
|
|
export const lockedAtom = atom((get) => get(proxyStateAtom)?.locked ?? null);
|
|
|
|
/** When recording was seen to pause, in this session; null while recording or not known */
|
|
export const pausedAtAtom = atom<number | null>(null);
|
|
|
|
/**
|
|
* Changes applied while a page of older flows loads, or null when none is loading. A slow flow
|
|
* that ends meanwhile may be older than the window, so left out of it, and missing from a page
|
|
* read before it was written; applying these again once the page is in keeps it.
|
|
*/
|
|
let changesDuringPage: ModelPayload[] | null = null;
|
|
|
|
/**
|
|
* Flows the keep-traffic sweep deleted while a page was being read: the page may be a snapshot
|
|
* from before, so it is filtered by these as it lands. Forgotten once no page is being read.
|
|
*/
|
|
const removedWhileLoading = new Set<string>();
|
|
let pagesLoading = 0;
|
|
|
|
function pageStarted() {
|
|
pagesLoading++;
|
|
}
|
|
|
|
function pageSettled() {
|
|
pagesLoading = Math.max(0, pagesLoading - 1);
|
|
if (pagesLoading === 0) removedWhileLoading.clear();
|
|
}
|
|
|
|
/** A page's flows less any the sweep deleted since it was asked for */
|
|
function withoutRemoved(flows: Flow[]): Flow[] {
|
|
return removedWhileLoading.size === 0
|
|
? flows
|
|
: flows.filter((f) => !removedWhileLoading.has(f.id));
|
|
}
|
|
|
|
/** Changes still to apply, less the ones that would bring back a deleted flow */
|
|
function dropRemovedChanges(changes: ModelPayload[], removed: Set<string>) {
|
|
for (let i = changes.length - 1; i >= 0; i--) {
|
|
const c = changes[i];
|
|
if (c != null && c.change.type !== "delete" && removed.has(c.model.id)) changes.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/** Loads the page of flows before the oldest one shown, from the search's flows when searching */
|
|
export const loadOlderFlowsAtom = atom(null, async (get, set) => {
|
|
const search = get(searchWindowAtom);
|
|
const { flows, hasOlder } = search?.win ?? get(flowWindowAtom);
|
|
const first = flows[0];
|
|
if (changesDuringPage != null || !hasOlder || first == null) return;
|
|
const changes: ModelPayload[] = [];
|
|
changesDuringPage = changes;
|
|
pageStarted();
|
|
try {
|
|
const res = await rpc("list_flows", { ...search?.query, before: first.id, limit: PAGE_SIZE });
|
|
// Dropped when the window moved on meanwhile, trimmed, cleared or searched again.
|
|
const merge = (win: FlowWindow) => {
|
|
if (win.flows[0]?.id !== first.id) return win;
|
|
const merged = prependOlder(win, withoutRemoved(res.flows), PAGE_SIZE);
|
|
const kept = search == null ? changes : changesOf(search, merged, changes);
|
|
// Counted when they first applied.
|
|
return { ...applyChanges(merged, kept), total: merged.total };
|
|
};
|
|
if (search == null) {
|
|
set(flowWindowAtom, merge);
|
|
} else {
|
|
set(searchWindowAtom, (s) => (s?.key === search.key ? { ...s, win: merge(s.win) } : s));
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to load older flows", err);
|
|
} finally {
|
|
changesDuringPage = null;
|
|
pageSettled();
|
|
}
|
|
});
|
|
|
|
/** Changes applied while a page of a sort loads, applied again over it once it is in */
|
|
let changesDuringSortedPage: ModelPayload[] | null = null;
|
|
|
|
/** The key flow events mark matches of the search shown with, or null for all traffic */
|
|
function searchKeyOf(search: SearchWindow | null): string | null {
|
|
return search?.key ?? null;
|
|
}
|
|
|
|
/** Loads the sort's next page, after the last flow shown, on scrolling to the bottom */
|
|
export const loadMoreSortedAtom = atom(null, async (get, set) => {
|
|
const win = get(sortedWindowAtom);
|
|
const cursor = win?.cursor;
|
|
if (win == null || win.loading || win.boundary == null || cursor == null) return;
|
|
if (changesDuringSortedPage != null) return;
|
|
const changes: ModelPayload[] = [];
|
|
changesDuringSortedPage = changes;
|
|
pageStarted();
|
|
try {
|
|
const res = await rpc("list_flows", {
|
|
...win.query,
|
|
sort: win.sort,
|
|
cursor,
|
|
limit: PAGE_SIZE,
|
|
});
|
|
set(sortedWindowAtom, (s) => {
|
|
if (s?.key !== win.key || s.cursor !== cursor) return s;
|
|
const kept = new Set(withoutRemoved(res.flows).map((f) => f.id));
|
|
const merged = appendPage(s, res.flows, res.cursor, PAGE_SIZE, (f) => kept.has(f.id));
|
|
const compare = sortComparator(s.sort);
|
|
const key = searchKeyOf(get(searchWindowAtom));
|
|
return {
|
|
...merged,
|
|
flows: applySortedChanges(merged.flows, merged.boundary, changes, compare, key),
|
|
};
|
|
});
|
|
} catch (err) {
|
|
// Read the sort again from its top.
|
|
console.warn("Failed to load more sorted flows", err);
|
|
reloadSorted?.();
|
|
} finally {
|
|
changesDuringSortedPage = null;
|
|
pageSettled();
|
|
}
|
|
});
|
|
|
|
/** Reads the sort's first page again, once `syncFlows` has started */
|
|
let reloadSorted: (() => void) | null = null;
|
|
|
|
/** `changesFor`, with flows matched against another search sent to be matched again */
|
|
function changesOf(
|
|
search: Pick<SearchWindow, "key" | "query">,
|
|
win: FlowWindow,
|
|
changes: ModelPayload[],
|
|
): ModelPayload[] {
|
|
return changesFor(search.key, win, changes, (unknown) => matchLater(search, unknown));
|
|
}
|
|
|
|
/** The store `syncFlows` keeps, for answers that come after the changes they are for */
|
|
let syncedStore: Store | null = null;
|
|
|
|
/** The order flow events arrived in, for judging answers that come late */
|
|
const flowEvents = new FlowEvents();
|
|
|
|
/**
|
|
* Matches flows against the search in the database, then has its window take them as it takes
|
|
* events matched as they were sent. A flow a newer event updated or removed meanwhile, in the
|
|
* window or not, keeps what that event left.
|
|
*/
|
|
function matchLater(search: Pick<SearchWindow, "key" | "query">, changes: ModelPayload[]) {
|
|
const store = syncedStore;
|
|
if (store == null) return;
|
|
const ids = [...new Set(changes.map((c) => c.model.id))];
|
|
rpc("match_flows", { filter: search.query, ids })
|
|
.then((matched) => {
|
|
const covered = new Set(matched);
|
|
const marked = flowEvents.current(changes).map((c) => ({
|
|
...c,
|
|
search: { key: search.key, matches: covered.has(c.model.id) },
|
|
}));
|
|
if (marked.length === 0) return;
|
|
applySortedChangesTo(store, marked);
|
|
const following = store.get(followingAtom);
|
|
store.set(searchWindowAtom, (s) => {
|
|
if (s?.key !== search.key) return s;
|
|
if (s.loading) return { ...s, buffered: [...s.buffered, ...marked] };
|
|
const next = applyChanges(s.win, changesOf(s, s.win, marked));
|
|
return { ...s, win: following ? trimWindow(next) : next };
|
|
});
|
|
})
|
|
.catch((err) => console.warn("Failed to match flows", err));
|
|
}
|
|
|
|
/** Applies flow events to the sorted window, or holds them while its first page loads */
|
|
function applySortedChangesTo(store: Store, changes: ModelPayload[]) {
|
|
if (store.get(sortedWindowAtom) == null) return;
|
|
changesDuringSortedPage?.push(...changes);
|
|
const key = searchKeyOf(store.get(searchWindowAtom));
|
|
store.set(sortedWindowAtom, (s) => {
|
|
if (s == null) return s;
|
|
if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] };
|
|
const flows = applySortedChanges(s.flows, s.boundary, changes, sortComparator(s.sort), key);
|
|
return flows === s.flows ? s : { ...s, flows };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Loads the newest flows, then keeps them current with the engine's events, or with reads of the
|
|
* database while another process owns the engine. Events gather for `FLUSH_MS` and apply
|
|
* together, and the window drops its oldest flows while the list follows, so a new flow costs the
|
|
* same however many the database holds. A search gets a window of its own, kept current the same
|
|
* way.
|
|
*/
|
|
export function syncFlows(store: Store) {
|
|
syncedStore = store;
|
|
let pending: ModelPayload[] = [];
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let loaded = false;
|
|
let loads = 0;
|
|
let searchLoads = 0;
|
|
/** A flow written at or before the newest the app has seen, for reading new ones after it */
|
|
let seenId: string | null = null;
|
|
|
|
function flush() {
|
|
timer = null;
|
|
if (!loaded || pending.length === 0) return;
|
|
const changes = pending;
|
|
pending = [];
|
|
changesDuringPage?.push(...changes);
|
|
const following = store.get(followingAtom);
|
|
store.set(flowWindowAtom, (win) => {
|
|
const next = applyChanges(win, changes);
|
|
return following ? trimWindow(next) : next;
|
|
});
|
|
store.set(searchWindowAtom, (s) => {
|
|
if (s == null) return s;
|
|
// A matching flow written after the page's snapshot would otherwise be missed.
|
|
if (s.loading) return { ...s, buffered: [...s.buffered, ...changes] };
|
|
const next = applyChanges(s.win, changesOf(s, s.win, changes));
|
|
return { ...s, win: following ? trimWindow(next) : next };
|
|
});
|
|
applySortedChangesTo(store, changes);
|
|
const selected = store.get(selectedFlowAtom);
|
|
const change = changes.findLast((c) => c.model.id === selected?.id);
|
|
if (change != null) {
|
|
store.set(selectedFlowAtom, change.change.type === "delete" ? null : change.model);
|
|
}
|
|
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
|
}
|
|
|
|
function receive(changes: ModelPayload[]) {
|
|
flowEvents.arrived(changes);
|
|
pending.push(...changes);
|
|
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
|
|
}
|
|
|
|
// The total counts each new flow as it comes, which a flow both in a page and in the events
|
|
// around it can count twice or not at all; a count from the database a moment later settles it.
|
|
let recountTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function recount() {
|
|
recountTimer = null;
|
|
const thisLoad = loads;
|
|
void rpc("list_flows", { limit: 0 }).then((res) => {
|
|
if (thisLoad !== loads) return;
|
|
store.set(flowWindowAtom, (win) =>
|
|
win.total === res.total ? win : { ...win, total: res.total },
|
|
);
|
|
});
|
|
if (store.get(hideSystemAtom)) {
|
|
void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => {
|
|
if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total);
|
|
});
|
|
}
|
|
countSaved();
|
|
const search = store.get(searchWindowAtom);
|
|
if (search == null || search.loading) return;
|
|
void rpc("list_flows", { ...search.query, limit: 0 }).then((res) => {
|
|
const total = res.matching ?? res.total;
|
|
store.set(searchWindowAtom, (s) =>
|
|
s?.key !== search.key || s.win.total === total ? s : { ...s, win: { ...s.win, total } },
|
|
);
|
|
});
|
|
}
|
|
|
|
// Saved searches, and their counts, which change with the traffic.
|
|
function loadSaved() {
|
|
rpc("list_saved_queries", {})
|
|
.then((saved) => {
|
|
store.set(savedQueriesAtom, saved);
|
|
countSaved();
|
|
})
|
|
.catch((err) => console.warn("Failed to list saved searches", err));
|
|
}
|
|
let counting = false;
|
|
let countAgain = false;
|
|
function countSaved() {
|
|
if (store.get(savedQueriesAtom).length === 0) return;
|
|
// One count at a time; one asked for meanwhile runs after it, with the latest traffic.
|
|
if (counting) {
|
|
countAgain = true;
|
|
return;
|
|
}
|
|
counting = true;
|
|
rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) })
|
|
.then((counts) =>
|
|
store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))),
|
|
)
|
|
.catch((err) => console.warn("Failed to count saved searches", err))
|
|
.finally(() => {
|
|
counting = false;
|
|
if (countAgain) {
|
|
countAgain = false;
|
|
countSaved();
|
|
}
|
|
});
|
|
}
|
|
listen("saved_queries_changed", loadSaved);
|
|
loadSaved();
|
|
|
|
listen("model_write", (payload) => {
|
|
seenId = payload.model.id;
|
|
receive([payload]);
|
|
});
|
|
|
|
// Loads again rather than only emptying, for a flow written between the clear and the event.
|
|
// Saved searches too, which could not be read while the database was locked.
|
|
listen("flows_cleared", () => {
|
|
flowEvents.reset();
|
|
store.set(emptyFlowsAtom);
|
|
pending = [];
|
|
load();
|
|
loadSaved();
|
|
});
|
|
|
|
// The oldest flows, swept to keep the capture under its size limit: dropped wherever they
|
|
// are held, and the totals counted again from the database.
|
|
listen("flows_removed", ({ ids }) => {
|
|
flowEvents.removed(ids);
|
|
const removed = new Set(ids);
|
|
const drop = (win: FlowWindow) =>
|
|
win.flows.some((f) => removed.has(f.id))
|
|
? { ...win, flows: win.flows.filter((f) => !removed.has(f.id)) }
|
|
: win;
|
|
if (pagesLoading > 0) for (const id of ids) removedWhileLoading.add(id);
|
|
dropRemovedChanges(pending, removed);
|
|
if (changesDuringPage != null) dropRemovedChanges(changesDuringPage, removed);
|
|
store.set(flowWindowAtom, drop);
|
|
store.set(searchWindowAtom, (s) => {
|
|
if (s == null) return s;
|
|
const buffered = [...s.buffered];
|
|
dropRemovedChanges(buffered, removed);
|
|
return { ...s, win: drop(s.win), buffered };
|
|
});
|
|
if (changesDuringSortedPage != null) dropRemovedChanges(changesDuringSortedPage, removed);
|
|
store.set(sortedWindowAtom, (s) => {
|
|
if (s == null) return s;
|
|
const buffered = [...s.buffered];
|
|
dropRemovedChanges(buffered, removed);
|
|
const flows = s.flows.some((f) => removed.has(f.id))
|
|
? s.flows.filter((f) => !removed.has(f.id))
|
|
: s.flows;
|
|
return { ...s, flows, buffered };
|
|
});
|
|
if (removed.has(store.get(selectedFlowAtom)?.id ?? "")) store.set(selectedFlowAtom, null);
|
|
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
|
});
|
|
|
|
function load() {
|
|
loaded = false;
|
|
const thisLoad = ++loads;
|
|
pageStarted();
|
|
void rpc("list_flows", { limit: WINDOW_SIZE })
|
|
.then((res) => {
|
|
// A clear since asked for a newer page.
|
|
if (thisLoad !== loads) return;
|
|
store.set(
|
|
flowWindowAtom,
|
|
windowFromPage(withoutRemoved(res.flows), WINDOW_SIZE, res.total),
|
|
);
|
|
seenId = res.flows[0]?.id ?? null;
|
|
loaded = true;
|
|
store.set(flowsLoadedAtom, true);
|
|
// Flows recorded before this install remembered it mean setup was done.
|
|
if (res.total > 0) store.set(systemProxyEverOnAtom, true);
|
|
flush();
|
|
recount();
|
|
})
|
|
.finally(pageSettled);
|
|
loadSearch(store.get(activeQueryAtom));
|
|
loadSorted();
|
|
}
|
|
reloadFlows = load;
|
|
|
|
/**
|
|
* Reads the first page of the list's sort from the database, over the search shown, or drops
|
|
* the sorted window when there is no sort. Until the page is in, the flows loaded, sorted here,
|
|
* stand in for it.
|
|
*/
|
|
let sortedLoads = 0;
|
|
function loadSorted() {
|
|
const sort = store.get(flowSortAtom);
|
|
if (sort.length === 0) {
|
|
store.set(sortedWindowAtom, null);
|
|
return;
|
|
}
|
|
const query = store.get(activeQueryAtom);
|
|
const key = `${++sortedLoads}:${JSON.stringify({ sort, query })}`;
|
|
store.set(sortedWindowAtom, {
|
|
key,
|
|
sort,
|
|
query,
|
|
flows: [],
|
|
boundary: null,
|
|
cursor: null,
|
|
loading: true,
|
|
buffered: [],
|
|
});
|
|
const settle = (page: Flow[] | null, cursor: SortValue[] | null) =>
|
|
store.set(sortedWindowAtom, (s) => {
|
|
if (s?.key !== key) return s;
|
|
const ends = pageBoundary(page ?? [], cursor, PAGE_SIZE);
|
|
const base = page == null ? s.flows : withoutRemoved(page);
|
|
const search = searchKeyOf(store.get(searchWindowAtom));
|
|
const compare = sortComparator(sort);
|
|
const flows = applySortedChanges(base, ends.boundary, s.buffered, compare, search);
|
|
return { ...s, ...ends, flows, loading: false, buffered: [] };
|
|
});
|
|
pageStarted();
|
|
void rpc("list_flows", { ...query, sort, limit: PAGE_SIZE })
|
|
.then((res) => settle(res.flows, res.cursor))
|
|
.catch((err) => {
|
|
console.warn("Failed to read the sorted flows", err);
|
|
settle(null, null);
|
|
})
|
|
.finally(pageSettled);
|
|
}
|
|
reloadSorted = loadSorted;
|
|
let sortJson = JSON.stringify(store.get(flowSortAtom));
|
|
store.sub(flowSortAtom, () => {
|
|
const json = JSON.stringify(store.get(flowSortAtom));
|
|
if (json === sortJson) return;
|
|
sortJson = json;
|
|
loadSorted();
|
|
});
|
|
|
|
/**
|
|
* Loads the first page of `query`'s flows into a window of its own, or drops it for null. The
|
|
* backend matches the flows written from now on against it, and their events say so.
|
|
*/
|
|
function loadSearch(query: FlowQuery | null, keepRows = false) {
|
|
if (query == null) {
|
|
store.set(searchWindowAtom, null);
|
|
void rpc("watch_query", { key: "" }).catch(() => {});
|
|
return;
|
|
}
|
|
// Unique to this load, so a slower answer to an earlier one with the same query, such as
|
|
// one read before a clear, is dropped.
|
|
const key = `${++searchLoads}:${JSON.stringify(query)}`;
|
|
// Read again, it keeps showing its rows until the new ones are in.
|
|
const win = (keepRows ? store.get(searchWindowAtom)?.win : null) ?? emptyWindow;
|
|
store.set(searchWindowAtom, { key, query, win, loading: true, buffered: [] });
|
|
void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).catch(
|
|
(err) => console.warn("Failed to watch the search", err),
|
|
);
|
|
const settle = (page: FlowWindow | null) =>
|
|
store.set(searchWindowAtom, (s) => {
|
|
if (s?.key !== key) return s;
|
|
// Applied again over the page: a flow already in it is replaced, not added.
|
|
const base = page ?? s.win;
|
|
const win = applyChanges(base, changesOf(s, base, s.buffered));
|
|
return {
|
|
...s,
|
|
win: page == null ? win : { ...win, total: page.total },
|
|
loading: false,
|
|
buffered: [],
|
|
};
|
|
});
|
|
pageStarted();
|
|
void rpc("list_flows", { ...query, limit: WINDOW_SIZE })
|
|
.then((res) =>
|
|
settle(windowFromPage(withoutRemoved(res.flows), WINDOW_SIZE, res.matching ?? res.total)),
|
|
)
|
|
.catch((err) => {
|
|
console.warn("Failed to search flows", err);
|
|
settle(null);
|
|
})
|
|
.finally(pageSettled);
|
|
}
|
|
|
|
// The sidebar takes the window's flows at once when it empties or first fills, and at most
|
|
// once a second otherwise.
|
|
let sidebarTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const takeSidebarFlows = () => {
|
|
sidebarTimer = null;
|
|
store.set(sidebarFlowsAtom, store.get(flowsAtom));
|
|
};
|
|
store.sub(flowsAtom, () => {
|
|
const now = store.get(flowsAtom).length;
|
|
const before = store.get(sidebarFlowsAtom).length;
|
|
if (now === 0 || before === 0) {
|
|
if (sidebarTimer != null) clearTimeout(sidebarTimer);
|
|
takeSidebarFlows();
|
|
} else if (sidebarTimer == null) {
|
|
sidebarTimer = setTimeout(takeSidebarFlows, SIDEBAR_MS);
|
|
}
|
|
});
|
|
|
|
// All traffic's count, and the saved searches', leave System out while it is hidden.
|
|
store.sub(hideSystemAtom, () => {
|
|
if (loaded) recount();
|
|
});
|
|
|
|
// A search counting back from now, `@time:<5m`, is read again while it is shown and the list
|
|
// follows new traffic, so flows leave it and join it as they age. Saved ones are counted
|
|
// again, as their counts change with no traffic at all.
|
|
setInterval(() => {
|
|
if (anyCountsBackFromNow(store.get(savedQueriesAtom).map((q) => q.query))) countSaved();
|
|
const s = store.get(searchWindowAtom);
|
|
if (s == null || s.loading || !store.get(followingAtom)) return;
|
|
if (countsBackFromNow(s.query.query)) loadSearch(s.query, true);
|
|
}, AGE_REFRESH_MS);
|
|
|
|
let activeKey = JSON.stringify(store.get(activeQueryAtom));
|
|
store.sub(activeQueryAtom, () => {
|
|
const query = store.get(activeQueryAtom);
|
|
const key = JSON.stringify(query);
|
|
if (key === activeKey) return;
|
|
activeKey = key;
|
|
loadSearch(query);
|
|
loadSorted();
|
|
});
|
|
|
|
// While another process owns the engine no events come, so new flows are read from the
|
|
// database. Reading after a flow that started last can return a slow one already seen, which
|
|
// applies as an update; it never skips one.
|
|
let polling = false;
|
|
function readNewFlows() {
|
|
if (polling || !loaded) return;
|
|
polling = true;
|
|
const after = seenId;
|
|
const read =
|
|
after == null
|
|
? rpc("list_flows", { limit: WINDOW_SIZE })
|
|
: rpc("list_flows", { after, limit: WINDOW_SIZE });
|
|
read
|
|
.then((res) => {
|
|
if (res.flows.length === 0) return;
|
|
// More arrived than one read holds: load the newest again rather than leave a gap.
|
|
if (res.flows.length >= WINDOW_SIZE) {
|
|
load();
|
|
return;
|
|
}
|
|
if (seenId === after) seenId = res.flows[0]?.id ?? seenId;
|
|
const known = new Set(store.get(flowsAtom).map((f) => f.id));
|
|
receive(
|
|
[...res.flows].reverse().map((model) => ({
|
|
model,
|
|
change: { type: "upsert", created: !known.has(model.id) },
|
|
})),
|
|
);
|
|
})
|
|
// The flow read after was cleared: start again from the newest.
|
|
.catch(() => {
|
|
seenId = null;
|
|
load();
|
|
})
|
|
.finally(() => {
|
|
polling = false;
|
|
});
|
|
}
|
|
setInterval(() => {
|
|
if (store.get(proxyStateAtom)?.otherOwner != null) readNewFlows();
|
|
}, POLL_MS);
|
|
// One last read once another owner lets go, for flows it wrote after the last one.
|
|
let hadOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
|
store.sub(proxyStateAtom, () => {
|
|
const hasOtherOwner = store.get(proxyStateAtom)?.otherOwner != null;
|
|
if (hadOtherOwner && !hasOtherOwner) readNewFlows();
|
|
hadOtherOwner = hasOtherOwner;
|
|
});
|
|
|
|
load();
|
|
}
|
|
|
|
/** Loads the newest flows again, once `syncFlows` has started */
|
|
let reloadFlows: (() => void) | null = null;
|
|
|
|
/** Empties the list, the search's too, and their counts, as a clear leaves them */
|
|
const emptyFlowsAtom = atom(null, (_get, set) => {
|
|
set(flowWindowAtom, emptyWindow);
|
|
set(nonSystemTotalAtom, 0);
|
|
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] }));
|
|
set(sortedWindowAtom, (s) =>
|
|
s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] },
|
|
);
|
|
set(selectedFlowAtom, null);
|
|
set(followingAtom, true);
|
|
});
|
|
|
|
/**
|
|
* Clears every flow. The list and its counts empty at once; the database marks them cleared in a
|
|
* moment and deletes them in the background, and its event reloads what came since.
|
|
*/
|
|
export const clearFlowsAtom = atom(null, async (_get, set) => {
|
|
set(emptyFlowsAtom);
|
|
try {
|
|
await rpc("clear_flows", {});
|
|
} catch (err) {
|
|
// Nothing was cleared: show the flows again.
|
|
reloadFlows?.();
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
/** When the person last clicked or pressed a key in the sidebar */
|
|
let lastSidebarInput = 0;
|
|
|
|
/** How soon after a click or key a change of the sidebar's selection counts as theirs */
|
|
const SIDEBAR_INPUT_MS = 500;
|
|
|
|
/** Notes a click or key in the sidebar, so the selection change it makes writes the search */
|
|
export function noteSidebarInput() {
|
|
lastSidebarInput = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Keeps the sidebar's selection and the search box in step. Selecting rows writes their query
|
|
* into the box; typing keeps a row selected while its query is still one of the box's terms,
|
|
* and All traffic selected while the box is empty.
|
|
*/
|
|
export function syncSidebarSelection(store: Store) {
|
|
const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID);
|
|
let syncing = false;
|
|
|
|
// The tree writes its selection again as it renders, the same rows in a new array, or its last
|
|
// row when the selection was emptied; only a change of rows right after a click or a key in
|
|
// the sidebar is the person selecting.
|
|
let lastRows = store.get(selectedAtom).join("\n");
|
|
store.sub(selectedAtom, () => {
|
|
const ids = store.get(selectedAtom);
|
|
const key = ids.join("\n");
|
|
if (key === lastRows) return;
|
|
lastRows = key;
|
|
if (syncing || Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) return;
|
|
// Group headings are not filters.
|
|
const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID);
|
|
if (rows.length === 0) return;
|
|
const items = itemsById(store.get(sidebarTreeAtom));
|
|
const selected = rows.flatMap((id) => {
|
|
const item = items.get(id);
|
|
return item == null ? [] : [item];
|
|
});
|
|
if (selected.length === 0) return;
|
|
const text = selectionText(selected);
|
|
if (text === store.get(searchTextAtom)) return;
|
|
syncing = true;
|
|
store.set(setSearchTextAtom, text);
|
|
syncing = false;
|
|
});
|
|
|
|
store.sub(searchTextAtom, () => {
|
|
if (syncing) return;
|
|
const text = store.get(searchTextAtom);
|
|
const items = itemsById(store.get(sidebarTreeAtom));
|
|
const ids = store.get(selectedAtom);
|
|
const kept = ids.filter((id) => {
|
|
const item = items.get(id);
|
|
return item != null && item.query !== "" && queryInText(item.query, text);
|
|
});
|
|
const next = text.trim() === "" ? [ALL_ID] : kept;
|
|
if (next.length === ids.length && next.every((id, i) => id === ids[i])) return;
|
|
syncing = true;
|
|
store.set(selectedAtom, next);
|
|
syncing = false;
|
|
});
|
|
|
|
if (store.get(selectedAtom).length === 0) store.set(selectedAtom, [ALL_ID]);
|
|
}
|
|
|
|
/**
|
|
* Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not
|
|
* running here, so another process taking it or letting it go shows.
|
|
*/
|
|
export function syncProxyState(store: Store) {
|
|
let wasRecording: boolean | null = null;
|
|
store.sub(proxyStateAtom, () => {
|
|
const recording = store.get(proxyStateAtom)?.recording ?? null;
|
|
if (recording === wasRecording) return;
|
|
// Only a pause seen here has a time; one from before launch does not.
|
|
if (recording === false && wasRecording === true) store.set(pausedAtAtom, Date.now());
|
|
if (recording !== false) store.set(pausedAtAtom, null);
|
|
wasRecording = recording;
|
|
});
|
|
function refresh() {
|
|
rpc("get_proxy_state", {})
|
|
.then((state) => store.set(proxyStateAtom, state))
|
|
.catch((err) => console.warn("Failed to read the proxy state", err));
|
|
}
|
|
listen("proxy_state_changed", refresh);
|
|
setInterval(() => {
|
|
if (store.get(proxyStateAtom)?.state !== "running") refresh();
|
|
}, OWNER_POLL_MS);
|
|
refresh();
|
|
}
|
|
|
|
/** Reads the proxy state again, after an action that changes it without an event */
|
|
export async function refreshProxyState(store: Store) {
|
|
store.set(proxyStateAtom, await rpc("get_proxy_state", {}));
|
|
}
|