Put Yaak Proxy's search on the shared query engine (#826)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Fable 5.1 authored and GitHub committed 2026-11-08 09:07:04 -07:00
1 parent 7453869ac6
commit 77226dbedb
58 files changed
+4754 -1594

No files matched your search

+24 -12
View File
@@ -65,7 +65,7 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { Node as QueryNode, Schema } from "@yaakapp-internal/query";
import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query";
import { queryFilter } from "./core/Editor/filter/extension";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import { HttpMethodTag } from "./core/HttpMethodTag";
import {
Icon,
@@ -850,18 +850,27 @@ function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
/** The fields `getItemFields` fills in. Bare words match `getItemText` by word prefix */
const sidebarFilterColumns = [
"method",
"url",
"name",
"type",
"folder",
"grpc_service",
"grpc_method",
/**
* The fields `getItemFields` fills in, each with what it is and a term using it, as completion
* shows them. Bare words match `getItemText` by word prefix
*/
const sidebarFilterFields: [name: string, info: string, example: string][] = [
["method", "GET, POST, …", "@method:post"],
["url", "Request URL", "@url:*/users*"],
["name", "Request name", '@name:"Get user"'],
["type", "http, grpc or ws", "@type:grpc"],
["folder", "Parent folder", "@folder:auth"],
["grpc_service", "gRPC service", "@grpc_service:*Greeter"],
["grpc_method", "gRPC method", "@grpc_method:SayHello"],
];
const sidebarFilterColumns = sidebarFilterFields.map(([name]) => name);
const sidebarFilterSchema: Schema = {
fields: sidebarFilterColumns.map((name) => ({ name, kind: { type: "text" } })),
fields: sidebarFilterFields.map(([name, info, example]) => ({
name,
kind: { type: "text" },
info,
example,
})),
freeText: "fuzzy",
};
@@ -1005,9 +1014,12 @@ const sidebarTreeAtom = atom<
matcher.free();
markHidden(root);
// Values offered after `@field:`. URLs and names are left out: nearly every one is unique,
// so a list of them is noise rather than a shortcut.
const fieldValues: Record<string, string[]> = {};
for (const [name, values] of Object.entries(allFields)) {
fieldValues[name] = Array.from(values).filter((v) => v.length < 20);
if (name === "url" || name === "name") continue;
fieldValues[name] = Array.from(values);
}
const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
@@ -1,88 +0,0 @@
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { highlightingFor } from "@codemirror/language";
import type { EditorState, Extension } from "@codemirror/state";
import { RangeSetBuilder } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";
import type { Tag } from "@lezer/highlight";
import { tags as t } from "@lezer/highlight";
import type { Schema, TokenKind } from "@yaakapp-internal/query";
import { queryCompletions, queryTokens } from "@yaakapp-internal/query";
export interface QueryFilterOptions {
schema: Schema;
/** The values seen for each field path, offered after `@field:` */
values?: Record<string, string[]>;
}
const tokenTags: Record<TokenKind, Tag | null> = {
field: t.attributeName,
value: t.attributeValue,
operator: t.operatorKeyword,
quote: t.string,
paren: t.paren,
negation: t.operator,
text: null,
};
function buildDecorations(state: EditorState): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const token of queryTokens(state.doc.toString())) {
const tag = tokenTags[token.kind];
const className = tag == null ? null : highlightingFor(state, [tag]);
if (className == null || token.span.start === token.span.end) continue;
builder.add(token.span.start, token.span.end, Decoration.mark({ class: className }));
}
return builder.finish();
}
const highlight = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view.state);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.state);
}
}
},
{ decorations: (v) => v.decorations },
);
function completionSource({ schema, values }: QueryFilterOptions) {
return (ctx: CompletionContext): CompletionResult | null => {
const result = queryCompletions(ctx.state.doc.toString(), ctx.pos, schema, values);
if (result == null) return null;
const { start, end } = result.span;
// Field names pop up for something typed; on an empty spot, only when asked for
const onlyFields = result.items.every((item) => item.kind === "field");
if (onlyFields && start === end && !ctx.explicit) return null;
const options: Completion[] = result.items.map((item) => ({
label: item.label,
type: item.kind === "field" ? "property" : "constant",
info: item.info ?? undefined,
apply: (view, _completion, from, to) => {
view.dispatch({
changes: { from, to, insert: item.insert },
selection: { anchor: from + item.insert.length },
});
if (item.reopen) startCompletion(view);
},
}));
// The engine has already filtered and ranked the options
return { from: start, to: end, options, filter: false };
};
}
/** Highlighting and completion for a search box written in Yaak's query language */
export function queryFilter(options: QueryFilterOptions): Extension {
return [highlight, autocompletion({ override: [completionSource(options)] })];
}
+2 -2
View File
@@ -3,7 +3,7 @@ import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import { useEffect, useState } from "react";
import { rpc } from "../lib/rpc";
import { parseSearch, widerQueries } from "../lib/search";
import { activeQuery, widerQueries } from "../lib/query";
import {
hideSystemAtom,
pausedAtAtom,
@@ -77,7 +77,7 @@ function NoResults({ text }: { text: string }) {
const candidates = widerQueries(text);
void Promise.all(
candidates.map(async (candidate) => {
const query = parseSearch(candidate, hideSystem);
const query = activeQuery(candidate, hideSystem);
const res = await rpc("list_flows", { ...query, limit: 0 });
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
}),
@@ -22,6 +22,7 @@ import { LockedState } from "./LockedState";
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
import { HelperDialog } from "./HelperDialog";
import { SaveDialog } from "./SaveDialog";
import { SavedSearchDialog } from "./SavedSearchDialog";
import { SearchBar } from "./SearchBar";
import { SettingsWindow, useOpenSettings } from "./Settings";
import { Sidebar } from "./Sidebar";
@@ -158,6 +159,7 @@ export function ProxyLayout() {
</div>
</SidebarLayout>
<SaveDialog />
<SavedSearchDialog />
<HelperDialog />
<SettingsWindow />
<Toasts />
@@ -0,0 +1,82 @@
import { Dialog, DialogFooter, PlainInput } from "@yaakapp-internal/ui";
import { atom, useAtom } from "jotai";
import { useState } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useShowToast } from "../lib/toast";
/** Saving the search box's text under a name, or renaming a saved search */
export type SavedSearchDialog =
| { mode: "save"; query: string }
| { mode: "rename"; id: string; name: string };
export const savedSearchDialogAtom = atom<SavedSearchDialog | null>(null);
const FORM_ID = "saved-search";
/** Names a saved search, as `savedSearchDialogAtom` asks */
export function SavedSearchDialog() {
const [dialog, setDialog] = useAtom(savedSearchDialogAtom);
if (dialog == null) return null;
// Keyed, so each opening starts from its own name.
return (
<NameDialog
key={dialog.mode === "save" ? `save:${dialog.query}` : `rename:${dialog.id}`}
dialog={dialog}
onClose={() => setDialog(null)}
/>
);
}
function NameDialog({ dialog, onClose }: { dialog: SavedSearchDialog; onClose: () => void }) {
const [name, setName] = useState(dialog.mode === "rename" ? dialog.name : "");
const showToast = useShowToast();
const onError = (err: Error) =>
showToast({ id: "saved-search", message: err.message, color: "danger" });
const create = useRpcMutation("create_saved_query", { onSuccess: onClose, onError });
const rename = useRpcMutation("rename_saved_query", { onSuccess: onClose, onError });
const busy = create.isPending || rename.isPending;
const submit = () => {
if (name.trim() === "" || busy) return;
if (dialog.mode === "save") create.mutate({ name, query: dialog.query });
else rename.mutate({ id: dialog.id, name });
};
return (
<Dialog
open
onClose={onClose}
size="dynamic"
title={dialog.mode === "save" ? "Save search" : "Rename search"}
className="w-[26rem]"
>
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<PlainInput
label="Name"
autoFocus
defaultValue={name}
onChange={setName}
placeholder={dialog.mode === "save" ? dialog.query : undefined}
/>
</form>
<DialogFooter
actions={[
{ label: "Cancel", variant: "border", onClick: onClose },
{
label: dialog.mode === "save" ? "Save" : "Rename",
color: "primary",
form: FORM_ID,
disabled: name.trim() === "",
isLoading: busy,
},
]}
/>
</Dialog>
);
}
+92 -36
View File
@@ -1,34 +1,53 @@
import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui";
import { startCompletion } from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import {
Dropdown,
Icon,
IconButton,
Input,
type InputHandle,
LazyCodeEditor,
} from "@yaakapp-internal/ui";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { memo, type RefObject, useEffect, useRef } from "react";
import { memo, useEffect, useMemo, useRef } from "react";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
import {
allTrafficTotalAtom,
lockedAtom,
savedQueriesAtom,
searchTermsAtom,
searchTextAtom,
searchTextVersionAtom,
setSearchTextAtom,
} from "../lib/store";
import { savedSearchDialogAtom } from "./SavedSearchDialog";
/** How long typing settles before the search runs */
const SEARCH_DEBOUNCE_MS = 200;
/**
* The header's search over every flow in the database, by URL, method, status and content type,
* and the `@app`, `@host` and `@path` terms the sidebar writes. Its placeholder counts the flows.
* ⌘F focuses it and Escape clears it.
* The header's search over every flow in the database, in the query language: bare words match
* the URL, method, status and content type, and `@field:value` terms the rest, highlighted and
* completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs
* it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape
* clears it.
*/
export const SearchBar = memo(function SearchBar() {
const [text, setText] = useAtom(searchTextAtom);
const setTerms = useSetAtom(searchTermsAtom);
const setSearchText = useSetAtom(setSearchTextAtom);
const version = useAtomValue(searchTextVersionAtom);
const total = useAtomValue(allTrafficTotalAtom);
const store = useStore();
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
// Nothing to search while captured traffic is locked.
const locked = useAtomValue(lockedAtom) != null;
// Read, not subscribed: `PlaceholderCount` keeps it current without re-rendering the field.
const total = useStore().get(allTrafficTotalAtom);
// PlainInput hands back its <input> as the ref
const inputRef = useRef<HTMLInputElement>(null);
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
const handleRef = useRef<InputHandle | null>(null);
useEffect(() => {
const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS);
@@ -39,43 +58,69 @@ export const SearchBar = memo(function SearchBar() {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
handleRef.current?.selectAll();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// The language goes in once the schema is read: into the editor there is, through a
// compartment, and into each one made after, as taking text from the sidebar makes one.
const compartment = useRef(new Compartment());
const filter = useMemo<Extension>(
() =>
schema == null
? []
: queryFilter({
schema,
loadValues: loadSeenValues,
// Read as completion opens, so saved searches are current without remaking it.
moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed),
problems: searchProblems,
}),
[schema, store],
);
const extensions = useMemo(() => compartment.current.of(filter), [filter]);
useEffect(() => {
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
}, [filter]);
// The field is replaced to take a value from outside, so focus moves to the new one.
const clear = () => {
setSearchText("");
requestAnimationFrame(() => inputRef.current?.focus());
requestAnimationFrame(() => handleRef.current?.focus());
};
return (
<PlainInput
ref={inputRef}
<Input
renderEditor={(props) => (
<LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} />
)}
setRef={(h) => {
handleRef.current = h;
}}
label="Search flows"
hideLabel
size="sm"
className="font-mono text-xs"
stateKey={null}
language={null}
wrapLines={false}
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
disabled={locked}
defaultValue={text}
forceUpdateKey={String(version)}
onChange={setText}
onKeyDownCapture={(e) => {
if (e.key === "Escape" && text !== "") {
extensions={extensions}
onKeyDown={(e) => {
if (e.key === "Escape" && handleRef.current?.value() !== "") {
e.stopPropagation();
clear();
}
}}
leftSlot={
<>
<Icon icon="search" size="sm" className="ml-2 mr-2 self-center text-text-subtlest" />
<PlaceholderCount inputRef={inputRef} />
</>
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
}
rightSlot={
text === "" ? (
@@ -83,14 +128,26 @@ export const SearchBar = memo(function SearchBar() {
⌘F
</kbd>
) : (
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
className="self-center ml-2 mr-0.5"
onClick={clear}
/>
<div className="flex items-center self-center ml-1 mr-0.5">
<Dropdown
items={[
{
label: "Save search",
leftSlot: <Icon icon="bookmark" />,
onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }),
},
]}
>
<IconButton size="xs" icon="ellipsis" title="Search actions" iconColor="secondary" />
</Dropdown>
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
onClick={clear}
/>
</div>
)
}
/>
@@ -98,15 +155,14 @@ export const SearchBar = memo(function SearchBar() {
});
/**
* Keeps the placeholder's count current as flows arrive, alone, so new traffic re-renders this
* and not the whole field
* Ctrl+Space lists what can be typed, fields and saved searches on an empty spot, as it does in
* Yaak's editors, whose shared editor leaves the key to each app
*/
function PlaceholderCount({ inputRef }: { inputRef: RefObject<HTMLInputElement | null> }) {
const total = useAtomValue(allTrafficTotalAtom);
useEffect(() => {
if (inputRef.current != null) inputRef.current.placeholder = placeholderFor(total);
}, [inputRef, total]);
return null;
function startCompletionOnCtrlSpace(e: KeyboardEvent, view: EditorView): boolean {
if (!(e.ctrlKey && e.key === " " && !e.metaKey && !e.altKey && !e.shiftKey)) return false;
e.preventDefault();
startCompletion(view);
return true;
}
/** "Search 4,102 flows", the title and count that sat in the header's middle */
+35 -3
View File
@@ -7,10 +7,11 @@ import {
type TreeProps,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { atom, useAtom, useAtomValue } from "jotai";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { useCallback, useEffect } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { SIDEBAR_TREE_ID } from "../lib/atoms";
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
@@ -18,6 +19,7 @@ import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
import { savedSearchDialogAtom } from "./SavedSearchDialog";
/** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */
const expandedAtom = atomWithStorage<Record<string, boolean>>("sidebar_expanded", {}, undefined, {
@@ -63,6 +65,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
<div className="flex items-center gap-2 w-full min-w-0">
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
{item.appPath != null && <AppIcon path={item.appPath} />}
<span className="truncate">{item.label}</span>
@@ -119,17 +122,46 @@ function HideSystemToggle() {
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
const getItemKey = useCallback(
(item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}`,
// The label too, as a saved search's changes when it is renamed
(item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}:${item.label}`,
[],
);
const decryptList = useDecryptList();
const setDecryptList = useEditDecryptList();
const { mutate: saveDecryptList } = setDecryptList;
const showToast = useShowToast();
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", {
onError: (err) => showToast({ id: "saved-search", message: err.message, color: "danger" }),
});
// Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on.
// A saved search is renamed or deleted.
const getContextMenu = useCallback(
(items: SidebarItem[]): DropdownItem[] => {
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
if (saved.length > 0) {
const [first] = saved;
return [
{
label: "Rename",
leftSlot: <Icon icon="pencil" />,
hidden: saved.length > 1 || first == null,
onSelect: () => {
if (first != null)
setSavedDialog({ mode: "rename", id: first.id, name: first.label });
},
},
{
label: "Delete",
color: "danger",
leftSlot: <Icon icon="trash" />,
onSelect: () => {
for (const s of saved) deleteSaved({ id: s.id });
},
},
];
}
const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt]));
const action = decryptMenuAction(decryptList, subjects);
if (action == null) return [];
@@ -155,7 +187,7 @@ export function Sidebar() {
},
];
},
[decryptList, saveDecryptList, showToast],
[decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved],
);
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
({ items, position, onClose }) =>
+1
View File
@@ -51,6 +51,7 @@ const base: Flow = {
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
agentRead: false,
clientAddr: "127.0.0.1:54321",
elapsedConnectMs: 18,
elapsedTlsMs: 41,
@@ -50,6 +50,7 @@ const flowBase: Flow = {
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
agentRead: false,
clientAddr: null,
elapsedConnectMs: null,
elapsedTlsMs: null,
+3
View File
@@ -24,5 +24,8 @@
<script type="module" src="/font-size.ts"></script>
<script type="module" src="/lib/theme.ts"></script>
<script type="module" src="/main.tsx"></script>
<div id="cm-portal" class="cm-portal"></div>
<div id="react-portal"></div>
<div id="radix-portal" class="cm-portal"></div>
</body>
</html>
+1
View File
@@ -59,6 +59,7 @@ const flow: Flow = {
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
agentRead: false,
clientAddr: null,
elapsedConnectMs: null,
elapsedTlsMs: null,
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, test } from "vite-plus/test";
import {
activeQuery,
anyCountsBackFromNow,
asOneTerm,
countsBackFromNow,
fieldTerm,
pathTerm,
termKeys,
widerQueries,
} from "./query";
import { savedCompletions } from "./searchBox";
describe("terms", () => {
test("quote values that need it", () => {
expect(fieldTerm("app", "Google Chrome")).toBe('@app:"Google Chrome"');
expect(fieldTerm("host", "api.x.com")).toBe("@host:api.x.com");
expect(fieldTerm("is", "system", true)).toBe("-@is:system");
});
test("select a path and what is under it", () => {
expect(pathTerm("/v1/charges")).toBe("@path:/v1/charges/*");
expect(pathTerm("/a b")).toBe('@path:"/a b"');
});
test("are read back as written, any case, with their exclusion", () => {
expect(termKeys('@app:"Google Chrome" -@is:System (a OR -b)')).toEqual([
'@app:"google chrome"',
"-@is:system",
"a",
"-b",
]);
expect(termKeys("")).toEqual([]);
});
});
describe("activeQuery", () => {
test("is null for all traffic, and carries hiding System", () => {
expect(activeQuery(" ", false)).toBeNull();
expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true });
expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false });
});
});
describe("widerQueries", () => {
test("leaves out each term in turn", () => {
expect(widerQueries('@app:Slack @status:5xx "card declined"')).toEqual([
'@status:5xx "card declined"',
'@app:Slack "card declined"',
"@app:Slack @status:5xx",
]);
expect(widerQueries("@app:Slack")).toEqual([]);
});
test("widens one OR group at a time, keeping the groups", () => {
const text = "@app:Slack @host:absent.com OR @app:Chrome @host:absent.com";
expect(widerQueries(text)).toEqual([
"@host:absent.com OR @app:Chrome @host:absent.com",
"@app:Slack OR @app:Chrome @host:absent.com",
"@app:Slack @host:absent.com OR @host:absent.com",
"@app:Slack @host:absent.com OR @app:Chrome",
]);
// A group of one term is not emptied, which would take in every flow.
expect(widerQueries("@app:Slack OR @app:Chrome @host:b.com")).toEqual([
"@app:Slack OR @host:b.com",
"@app:Slack OR @app:Chrome",
]);
});
test("keeps groups and exclusions whole", () => {
expect(widerQueries("(@status:4xx OR @status:5xx) -(a OR b) c")).toEqual([
"-(a OR b) c",
"(@status:4xx OR @status:5xx) c",
"(@status:4xx OR @status:5xx) -(a OR b)",
]);
});
});
describe("countsBackFromNow", () => {
test("is a @time comparison or range, which ages", () => {
expect(countsBackFromNow("@time:<5m")).toBe(true);
expect(countsBackFromNow("x -(@time:[1h TO 2h] OR y)")).toBe(true);
expect(countsBackFromNow("@time:2026-11-07")).toBe(false);
expect(countsBackFromNow("@status:<500 time")).toBe(false);
});
test("of saved searches, tells when their counts change with time alone", () => {
expect(anyCountsBackFromNow(["@status:4xx", "-@time:>1h stripe"])).toBe(true);
expect(anyCountsBackFromNow(["@status:4xx", "@time:2026-11-07"])).toBe(false);
expect(anyCountsBackFromNow([])).toBe(false);
});
});
describe("saved searches as completions", () => {
test("keep an OR at their top in one group, however it is spaced", () => {
expect(asOneTerm("@status:4xx OR @status:5xx")).toBe("(@status:4xx OR @status:5xx)");
expect(asOneTerm("(@status:4xx)OR(@status:5xx)")).toBe("((@status:4xx)OR(@status:5xx))");
expect(asOneTerm("(a OR b) c")).toBe("(a OR b) c");
const saved = [
{ id: "1", name: "Errors", query: "(@status:4xx)OR(@status:5xx)", sortOrder: 0 },
{ id: "2", name: "Stripe", query: "@host:*.stripe.com", sortOrder: 1 },
];
expect(savedCompletions(saved, "err").map((c) => c.apply)).toEqual([
"((@status:4xx)OR(@status:5xx))",
]);
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { Node } from "@yaakapp-internal/query";
import { formatField, parseQuery } from "@yaakapp-internal/query";
/**
* A search over every flow in the database, as `list_flows` takes it: the search box's text,
* which the backend parses and runs, and whether System's flows are hidden.
*/
export interface FlowQuery {
query: string;
hideSystem: boolean;
}
/** The search the list shows, or null for all traffic */
export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null {
if (text.trim() === "" && !hideSystem) return null;
return { query: text.trim(), hideSystem };
}
/** `@field:value`, or `-@field:value`, the value quoted when it needs to be */
export function fieldTerm(field: string, value: string, negated = false): string {
return formatField(field, value, negated);
}
/** A site, its domain and every host below it: `@host:*.github.com` */
export function siteTerm(site: string): string {
const term = formatField("host", site);
return term.endsWith('"') ? term : `@host:*.${site}`;
}
/**
* A path and everything under it, `@path:/v1/charges/*`, as a sidebar row selects it. A path
* that has to be quoted cannot carry the wildcard, so it matches as written.
*/
export function pathTerm(path: string): string {
const term = formatField("path", path);
if (term.endsWith('"')) return term;
return `${term.replace(/\/+$/, "")}/*`;
}
/** `node` written back as query text, from its source, groups in parentheses where they need */
export function nodeText(node: Node, source: string, nested = false): string {
switch (node.type) {
case "And": {
const text = node.children.map((c) => nodeText(c, source, true)).join(" ");
return nested ? `(${text})` : text;
}
case "Or": {
const text = node.children.map((c) => nodeText(c, source, true)).join(" OR ");
return nested ? `(${text})` : text;
}
case "Not":
return `-${nodeText(node.node, source, true)}`;
default:
return source.slice(node.span.start, node.span.end);
}
}
/** The terms of a query, each as written and with a `-` when it is excluded, lowercased */
export function termKeys(text: string): string[] {
const keys: string[] = [];
const walk = (node: Node, negated: boolean) => {
switch (node.type) {
case "And":
case "Or":
for (const c of node.children) walk(c, negated);
break;
case "Not":
walk(node.node, !negated);
break;
default:
keys.push(
`${negated ? "-" : ""}${text.slice(node.span.start, node.span.end).toLowerCase()}`,
);
}
};
const root = parseQuery(text);
if (root != null) walk(root, false);
return keys;
}
/**
* Wider searches for one that matched nothing: the text with each of its terms left out in
* turn, without repeats. With groups `OR` separates, a term is left out of one group at a time
* and the groups kept, since each group alone is narrower still; a group left with nothing would
* take in every flow, so it is not offered.
*/
export function widerQueries(text: string): string[] {
const root = parseQuery(text);
if (root == null) return [];
const clauses = root.type === "Or" ? root.children : [root];
const termsOf = (clause: Node) => (clause.type === "And" ? clause.children : [clause]);
const write = (terms: Node[]) => terms.map((t) => nodeText(t, text, true)).join(" ");
const out = new Set<string>();
clauses.forEach((clause, i) => {
const terms = termsOf(clause);
if (terms.length <= 1) return;
terms.forEach((_, j) => {
const wider = clauses.map((c, k) =>
k === i ? write(terms.filter((_, l) => l !== j)) : write(termsOf(c)),
);
out.add(wider.join(" OR "));
});
});
return [...out];
}
/**
* The search has a `@time` term that counts back from now, `@time:<5m`, so which flows it covers
* changes as time passes, not only as flows are written
*/
export function countsBackFromNow(text: string): boolean {
let found = false;
const walk = (node: Node) => {
switch (node.type) {
case "And":
case "Or":
for (const c of node.children) walk(c);
break;
case "Not":
walk(node.node);
break;
case "Field":
if (
node.path.length === 1 &&
node.path[0]?.toLowerCase() === "time" &&
(node.value.type === "compare" || node.value.type === "range")
) {
found = true;
}
break;
default:
}
};
const root = parseQuery(text);
if (root != null) walk(root);
return found;
}
/** Any of the searches counts back from now, so its count changes as time passes */
export function anyCountsBackFromNow(queries: string[]): boolean {
return queries.some(countsBackFromNow);
}
/** `text` to sit beside other terms as one: in parentheses when it is an `OR` at its top */
export function asOneTerm(text: string): string {
return parseQuery(text)?.type === "Or" ? `(${text})` : text;
}
+29 -258
View File
@@ -1,144 +1,6 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import {
appKeyOf,
formatFieldFilter,
isSystemFlow,
matchesQuery,
parseSearch,
sidebarPath,
siteOf,
tokenize,
widerQueries,
} from "./search";
function flow(url: string, extra: Partial<Flow> = {}): Flow {
return {
url,
method: "GET",
status: 200,
contentType: null,
clientBundleId: null,
clientPath: null,
...extra,
} as Flow;
}
// The same flows and terms as `search_matches_url_method_status_and_content_type` in
// `flows.rs`, so the live matcher and the database agree.
const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", {
method: "POST",
contentType: "application/json; charset=utf-8",
});
const github = flow("https://api.github.com/repos/yaak", {
status: 404,
contentType: "application/json; charset=utf-8",
});
const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" });
const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 });
const all = [stripe, github, logo, health];
const search = (q: string) => all.filter((f) => matchesQuery(f, { q }));
describe("matchesQuery", () => {
test("matches the host, the path and the query, any case", () => {
expect(search("STRIPE")).toEqual([stripe]);
expect(search("/repos/")).toEqual([github]);
expect(search("cus_9")).toEqual([stripe]);
});
test("matches the whole method", () => {
expect(search("head")).toEqual([health]);
expect(search("ead")).toEqual([]);
});
test("matches a status, its start, or its class", () => {
expect(search("404")).toEqual([github]);
expect(search("50")).toEqual([health]);
expect(search("2xx")).toEqual([stripe, logo]);
});
test("matches the content type", () => {
expect(search("png")).toEqual([logo]);
expect(search("json")).toEqual([stripe, github]);
});
test("needs every term to match", () => {
expect(search("json get")).toEqual([github]);
expect(search(" json 2xx ")).toEqual([stripe]);
expect(search("json 5xx")).toEqual([]);
expect(search("")).toEqual(all);
});
test("keeps flows under a host or path, as the sidebar places them", () => {
const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] });
expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true);
expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true);
// Another port is a host node of its own, as in the sidebar.
expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false);
// Empty segments and default ports are left out, as the sidebar leaves them.
expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true);
expect(under("x.com/a", "http://x.com:80/a")).toBe(true);
expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false);
expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe(
true,
);
expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false);
expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false);
// The sidebar lowercases hosts; paths keep their case.
expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true);
expect(under("example.com/a", "http://example.com/A")).toBe(false);
});
test("keeps flows from an app, by bundle id or path", () => {
const chrome = flow("https://example.com/", {
clientBundleId: "com.google.Chrome",
clientPath: "/Applications/Google Chrome.app",
});
const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" });
const unknown = flow("https://example.com/");
const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app }));
expect(from(["com.google.Chrome"])).toEqual([chrome]);
expect(from(["/usr/bin/curl"])).toEqual([curl]);
expect(from(["/Applications/Google Chrome.app"])).toEqual([]);
});
test("keeps flows from an app by name, or a program by the folder it ran in", () => {
// The flows `filters_by_app_name_folder_site_and_system` in `flows.rs` checks.
const slack = flow("https://edgeapi.slack.com/cache", {
clientName: "Slack",
clientBundleId: "com.tinyspeck.slackmacgap",
clientPath: "/Applications/Slack.app",
});
const node = flow("https://api.github.com:443/repos", {
clientName: "node",
clientPath: "/opt/homebrew/bin/node",
clientCwd: "/Users/me/src/fieldwork",
});
const daemon = flow("https://github.com/login", {
clientName: "nsurlsessiond",
clientPath: "/usr/libexec/nsurlsessiond",
clientCwd: "/",
});
const none = flow("http://notgithub.com/");
const flows = [slack, node, daemon, none];
const where = (query: Parameters<typeof matchesQuery>[1]) =>
flows.filter((f) => matchesQuery(f, query));
expect(where({ app: ["slack"] })).toEqual([slack]);
expect(where({ app: ["Fieldwork"] })).toEqual([node]);
expect(where({ app: ["/opt/homebrew/bin/node"] })).toEqual([node]);
expect(where({ under: ["*.github.com"] })).toEqual([node, daemon]);
expect(where({ under: ["api.github.com"] })).toEqual([node]);
expect(where({ system: true })).toEqual([daemon]);
expect(where({ system: false })).toEqual([slack, node, none]);
expect(flows.map(appKeyOf)).toEqual(["Slack", "fieldwork", "nsurlsessiond", null]);
});
test("combines terms with the sidebar's selection", () => {
expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true);
expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false);
});
});
import { appKeyOf, isSystemFlow, sidebarPath, siteOf } from "./search";
describe("sidebarPath", () => {
// The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree.
@@ -159,125 +21,6 @@ describe("sidebarPath", () => {
});
});
describe("parseSearch", () => {
test("is null for text that narrows nothing", () => {
expect(parseSearch("")).toBeNull();
expect(parseSearch(" ")).toBeNull();
expect(parseSearch("OR")).toBeNull();
});
test("keeps free words as terms, trimmed and collapsed", () => {
expect(parseSearch(" stripe post ")).toEqual({ q: "stripe post" });
});
test("reads apps, hosts and the paths below them", () => {
expect(parseSearch('@app:"Google Chrome" @app:Slack card')).toEqual({
q: "card",
app: ["Google Chrome", "Slack"],
});
expect(parseSearch("@app:fieldwork @host:api.stripe.com @path:/v1/payment_intents")).toEqual({
app: ["fieldwork"],
under: ["api.stripe.com/v1/payment_intents"],
});
expect(parseSearch("@host:*.github.com")).toEqual({ under: ["*.github.com"] });
expect(parseSearch("@host:github.com @path:/mountain-loop/yaak/*")).toEqual({
under: ["github.com/mountain-loop/yaak"],
});
// A path with no host matches anywhere in the URL.
expect(parseSearch("@path:/v1")).toEqual({ q: "/v1" });
// Each path stays with the host before it.
expect(parseSearch("@host:a.com @path:/x @host:b.com @path:/y")).toEqual({
under: ["a.com/x", "b.com/y"],
});
});
test("keeps each clause OR separates as a group of its own", () => {
// Two selected sidebar rows under different apps: Slack on a.com, or Chrome on b.com.
const text = "@app:Slack @host:a.com OR @app:Chrome @host:b.com";
const query = parseSearch(text);
expect(query).toEqual({
any: [
{ app: ["Slack"], under: ["a.com"] },
{ app: ["Chrome"], under: ["b.com"] },
],
});
const on = (clientName: string, host: string) =>
flow(`https://${host}/x`, { clientName, clientBundleId: `app.${clientName}` });
const match = (f: Flow) => query != null && matchesQuery(f, query);
expect(match(on("Slack", "a.com"))).toBe(true);
expect(match(on("Chrome", "b.com"))).toBe(true);
// Not every pairing of the two rows' apps and hosts.
expect(match(on("Slack", "b.com"))).toBe(false);
expect(match(on("Chrome", "a.com"))).toBe(false);
// Words bind to their clause, and hiding System applies to each group that does not ask.
expect(parseSearch("@host:a.com OR @is:system @app:trustd", true)).toEqual({
any: [
{ under: ["a.com"], system: false },
{ app: ["trustd"], system: true },
],
});
// A clause that narrows nothing takes in every flow.
expect(parseSearch("@app:Slack OR")).toEqual({ app: ["Slack"] });
expect(parseSearch("@app:Slack OR -@foo:bar")).toBeNull();
});
test("keeps or drops System, which hiding it drops unless the text asks", () => {
expect(parseSearch("@is:system")).toEqual({ system: true });
expect(parseSearch("-@is:system stripe")).toEqual({ q: "stripe", system: false });
expect(parseSearch("", true)).toEqual({ system: false });
expect(parseSearch("@is:system", true)).toEqual({ system: true });
});
test("keeps or drops HTTPS passed through undecrypted", () => {
expect(parseSearch("@is:tunnelled")).toEqual({ tunnelled: true });
expect(parseSearch("-@is:tunnelled stripe")).toEqual({ q: "stripe", tunnelled: false });
const tunnel = { url: "https://a.com", method: "CONNECT", tunnelled: true } as Flow;
const decrypted = { url: "https://a.com/x", method: "GET", tunnelled: false } as Flow;
expect(matchesQuery(tunnel, { tunnelled: true })).toBe(true);
expect(matchesQuery(decrypted, { tunnelled: true })).toBe(false);
expect(matchesQuery(decrypted, { tunnelled: false })).toBe(true);
});
test("round-trips what the sidebar writes", () => {
const text = [
formatFieldFilter("app", "Google Chrome"),
formatFieldFilter("host", "api.x.com"),
].join(" ");
expect(text).toBe('@app:"Google Chrome" @host:api.x.com');
expect(tokenize(text)).toEqual([
{ field: "app", value: "Google Chrome", negated: false },
{ field: "host", value: "api.x.com", negated: false },
]);
});
});
describe("widerQueries", () => {
test("leaves out each term in turn", () => {
expect(widerQueries('@app:Slack @status:5xx "card declined"')).toEqual([
'@status:5xx "card declined"',
'@app:Slack "card declined"',
"@app:Slack @status:5xx",
]);
expect(widerQueries("@app:Slack")).toEqual([]);
});
test("widens one OR group at a time, keeping the groups", () => {
const text = "@app:Slack @host:absent.com OR @app:Chrome @host:absent.com";
expect(widerQueries(text)).toEqual([
"@host:absent.com OR @app:Chrome @host:absent.com",
"@app:Slack OR @app:Chrome @host:absent.com",
"@app:Slack @host:absent.com OR @host:absent.com",
"@app:Slack @host:absent.com OR @app:Chrome",
]);
// A group of one term is not emptied, which would take in every flow.
expect(widerQueries("@app:Slack OR @app:Chrome @host:b.com")).toEqual([
"@app:Slack OR @host:b.com",
"@app:Slack OR @app:Chrome",
]);
});
});
describe("isSystemFlow", () => {
// The cases `tells_system_daemons_apart` in `flows.rs` checks, so the two agree.
test("is a daemon outside an app, under the system's folders or by name", () => {
@@ -307,3 +50,31 @@ describe("siteOf", () => {
expect(siteOf("149.154.175.50")).toBe("149.154.175.50");
});
});
describe("appKeyOf", () => {
test("names an app, a program, or a runtime by the folder it ran in", () => {
const flow = (extra: Partial<Flow>) =>
({
clientName: null,
clientBundleId: null,
clientPath: null,
clientCwd: null,
...extra,
}) as Flow;
const flows = [
flow({ clientName: "Slack", clientBundleId: "com.tinyspeck.slackmacgap" }),
flow({
clientName: "node",
clientPath: "/opt/homebrew/bin/node",
clientCwd: "/src/fieldwork",
}),
flow({
clientName: "nsurlsessiond",
clientPath: "/usr/libexec/nsurlsessiond",
clientCwd: "/",
}),
flow({}),
];
expect(flows.map(appKeyOf)).toEqual(["Slack", "fieldwork", "nsurlsessiond", null]);
});
});
+4 -213
View File
@@ -1,147 +1,6 @@
import type { Flow, FlowFilter } from "@yaakapp-internal/proxy-lib";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { getDomain } from "tldts";
/**
* A search over every flow in the database: the search box's terms, its field terms, and
* whether System's flows are hidden, as `list_flows` takes them.
*/
export type FlowQuery = Pick<
FlowFilter,
"q" | "under" | "app" | "cwd" | "system" | "tunnelled" | "any"
>;
/** A word of the search box: a free term, or `@field:value`, maybe negated with `-` */
export interface SearchToken {
field: string | null;
value: string;
negated: boolean;
}
/**
* The search box's words. Values may be quoted, `@app:"Google Chrome"`; `OR` and `AND` between
* words are dropped here, and [`clausesOf`] splits on `OR`.
*/
export function tokenize(text: string): SearchToken[] {
return clausesOf(text).flat();
}
/**
* The search box's words in the clauses `OR` separates, each a group of terms that must all
* match: `@app:Slack @host:a.com OR @app:Chrome @host:b.com` is two. `AND` is dropped, since
* terms side by side must all match anyway.
*/
export function clausesOf(text: string): SearchToken[][] {
const clauses: SearchToken[][] = [[]];
const re = /(-)?(?:@([a-z]+):)?(?:"([^"]*)"?|(\S+))/gi;
for (const m of text.matchAll(re)) {
const [, minus, field, quoted, bare] = m;
const value = quoted ?? bare ?? "";
if (field == null && minus == null && quoted == null && value === "OR") {
clauses.push([]);
continue;
}
if (field == null && minus == null && quoted == null && value === "AND") continue;
if (field == null && value === "" && quoted == null) continue;
clauses.at(-1)?.push({ field: field?.toLowerCase() ?? null, value, negated: minus != null });
}
return clauses.filter((c) => c.length > 0);
}
/** `@field:value`, quoting a value with spaces or quotes in it */
export function formatFieldFilter(field: string, value: string, negated = false): string {
const v = /[\s"]/.test(value) || value === "" ? `"${value.replaceAll('"', "")}"` : value;
return `${negated ? "-" : ""}@${field}:${v}`;
}
/**
* The search box's text as a query, or null when it narrows nothing. Clauses `OR` separates are
* groups, each judged whole, so two sidebar rows under different apps match what each does and
* not every pairing of their apps and hosts. Within a clause, `@app` terms match any of their
* apps; `@host` terms any of their hosts, each with the `@path` terms after it; `@is:system`
* keeps only macOS's daemons and `-@is:system` drops them, as hiding System does when the
* clause says neither. `@is:tunnelled` keeps only HTTPS passed through undecrypted, and
* `-@is:tunnelled` drops it. Other words match the URL, the method, the status or the type.
*/
export function parseSearch(text: string, hideSystem = false): FlowQuery | null {
const clauses = clausesOf(text);
if (clauses.length <= 1) return parseClause(clauses[0] ?? [], hideSystem);
const any: FlowQuery[] = [];
for (const clause of clauses) {
const group = parseClause(clause, hideSystem);
// A clause that narrows nothing takes in every flow, so the whole does too.
if (group == null) return hideSystem ? { system: false } : null;
any.push(group);
}
return { any };
}
function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | null {
const words: string[] = [];
const apps: string[] = [];
const cwds: string[] = [];
// Each host with the paths written after it.
const hosts: { host: string; paths: string[] }[] = [];
let system: boolean | undefined = hideSystem ? false : undefined;
let tunnelled: boolean | undefined;
for (const t of tokens) {
if (t.field === "app" && !t.negated && t.value !== "") apps.push(t.value);
else if (t.field === "cwd" && !t.negated && t.value !== "") cwds.push(t.value);
else if (t.field === "host" && !t.negated && t.value !== "") {
hosts.push({ host: t.value.replace(/\/+$/, ""), paths: [] });
} else if (t.field === "path" && !t.negated && t.value !== "") {
const path = t.value.replace(/\/?\*$/, "").replace(/^\/+/, "");
const host = hosts.at(-1);
// A path with no host to sit under matches anywhere in the URL.
if (host == null) {
if (path !== "") words.push(`/${path}`);
} else {
host.paths.push(path);
}
} else if (t.field === "is" && t.value.toLowerCase() === "system") system = !t.negated;
else if (t.field === "is" && t.value.toLowerCase() === "tunnelled") tunnelled = !t.negated;
else if (t.field == null && !t.negated) words.push(t.value);
// Fields and exclusions the query language will add are ignored until it lands.
}
const under: string[] = [];
for (const { host, paths } of hosts) {
if (paths.length === 0 || host.startsWith("*.")) under.push(host);
else for (const p of paths) under.push(p === "" ? host : `${host}/${p}`);
}
const q = words.join(" ").trim();
const query: FlowQuery = {
...(q === "" ? {} : { q }),
...(under.length > 0 ? { under } : {}),
...(apps.length > 0 ? { app: apps } : {}),
...(cwds.length > 0 ? { cwd: cwds } : {}),
...(system == null ? {} : { system }),
...(tunnelled == null ? {} : { tunnelled }),
};
return Object.keys(query).length === 0 ? null : query;
}
/**
* Wider searches for one that matched nothing: the text with each of its terms left out in turn,
* without repeats. With groups `OR` separates, a term is left out of one group at a time and
* the groups kept, since each group alone is narrower still; a group left with nothing would
* take in every flow, so it is not offered.
*/
export function widerQueries(text: string): string[] {
const format = (t: SearchToken) =>
t.field == null
? `${t.negated ? "-" : ""}${/\s/.test(t.value) ? `"${t.value}"` : t.value}`
: formatFieldFilter(t.field, t.value, t.negated);
const clauses = clausesOf(text).map((c) => c.map(format));
const out = new Set<string>();
clauses.forEach((clause, i) => {
if (clause.length <= 1) return;
clause.forEach((_, j) => {
const wider = clauses.map((c, k) => (k === i ? c.filter((_, l) => l !== j) : c));
out.add(wider.map((c) => c.join(" ")).join(" OR "));
});
});
return [...out];
}
/** The folder a program outside an app ran in */
export function folderOf(flow: Pick<Flow, "clientBundleId" | "clientCwd">): string | null {
if (flow.clientBundleId != null) return null;
@@ -202,8 +61,8 @@ const SYSTEM_PROGRAMS = new Set([
/**
* A flow from one of macOS's own daemons: a process outside an app bundle whose executable is
* under `/System`, `/usr/libexec` and the like, or one of a few daemons by name, but not WebKit's
* helpers, which send apps' traffic. `is_system` in `crates-proxy/yaak-proxy-lib`, which the
* database filters by, follows the same rules.
* helpers, which send apps' traffic. `is_system` in `crates-proxy/yaak-proxy-lib`, which
* `@is:system` searches by, follows the same rules.
*/
export function isSystemFlow(flow: Pick<Flow, "clientBundleId" | "clientPath">): boolean {
const path = flow.clientPath;
@@ -213,66 +72,6 @@ export function isSystemFlow(flow: Pick<Flow, "clientBundleId" | "clientPath">):
return SYSTEM_DIRS.some((dir) => path.startsWith(dir)) || SYSTEM_PROGRAMS.has(name);
}
/**
* Whether `flow` is one `query` covers, as `list_flows` decides it in SQL, so new traffic joins a
* search as it arrives. Every term must match the URL, the method, the status or the content type.
*/
export function matchesQuery(flow: Flow, query: FlowQuery): boolean {
for (const term of (query.q ?? "").toLowerCase().split(/\s+/)) {
if (term === "") continue;
const matched =
flow.url.toLowerCase().includes(term) ||
flow.method.toLowerCase() === term ||
(flow.contentType?.toLowerCase().includes(term) ?? false) ||
matchesStatus(flow.status, term);
if (!matched) return false;
}
if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false;
if (query.app != null && !query.app.some((app) => matchesApp(flow, app))) return false;
if (query.cwd != null && !query.cwd.some((cwd) => matchesCwd(flow, cwd))) return false;
if (query.system != null && isSystemFlow(flow) !== query.system) return false;
if (query.tunnelled != null && flow.tunnelled !== query.tunnelled) return false;
// One of the groups, each judged whole, as the database judges them.
if (query.any != null && query.any.length > 0) {
return query.any.some((group) => matchesQuery(flow, group));
}
return true;
}
/** By bundle id or path exactly, or by name or folder in any case */
function matchesApp(flow: Flow, app: string): boolean {
if ((flow.clientBundleId ?? flow.clientPath) === app) return true;
const lower = app.toLowerCase();
return flow.clientName?.toLowerCase() === lower || folderOf(flow)?.toLowerCase() === lower;
}
/** By the whole working directory exactly, or its folder's name in any case */
function matchesCwd(flow: Flow, cwd: string): boolean {
if (flow.clientCwd == null) return false;
return flow.clientCwd === cwd || folderName(flow.clientCwd)?.toLowerCase() === cwd.toLowerCase();
}
/** `404` exactly, `40` as the start of one, or a class such as `4xx` */
function matchesStatus(status: number | null, term: string): boolean {
if (status == null) return false;
const klass = /^([1-9])xx$/.exec(term);
if (klass != null) return Math.trunc(status / 100) === Number(klass[1]);
if (!/^\d{1,3}$/.test(term)) return false;
return term.length === 3 ? status === Number(term) : String(status).startsWith(term);
}
/** The URL is the host or path `node` names, or something below it, by the sidebar's rules */
function isUnder(url: string, node: string): boolean {
const path = sidebarPath(url);
if (node.startsWith("*.") && !node.includes("/")) {
const site = node.slice(2).toLowerCase();
const host = hostnameOf(path.split("/")[0] ?? "");
return host === site || host.endsWith(`.${site}`);
}
const want = normalizeNode(node);
return path === want || path.startsWith(`${want}/`);
}
/** A host without its port, keeping an IPv6 address's brackets */
export function hostnameOf(host: string): string {
if (host.startsWith("[")) {
@@ -304,7 +103,7 @@ export function siteOf(host: string): string {
* Where the sidebar places a URL: its host, lowercased and without its scheme's default port,
* then its path's non-empty segments, joined by `/`, as in `api.stripe.com/v1/charges`. A URL
* without a scheme is a host of its own. `sidebar_path` in `crates-proxy/yaak-proxy-lib`, which
* the database filters by, follows the same rules.
* `@host` searches by, follows the same rules.
*/
export function sidebarPath(url: string): string {
const schemeEnd = url.indexOf("://");
@@ -326,11 +125,3 @@ export function sidebarPath(url: string): string {
const segments = path.split("/").filter((s) => s !== "");
return [host, ...segments].join("/");
}
/** A sidebar node as `sidebarPath` writes it, its host lowercased */
function normalizeNode(node: string): string {
const slash = node.indexOf("/");
return slash === -1
? node.toLowerCase()
: `${node.slice(0, slash).toLowerCase()}${node.slice(slash)}`;
}
+79
View File
@@ -0,0 +1,79 @@
import type { Completion } from "@codemirror/autocomplete";
import type { QueryDiagnostic, SavedQuery } from "@yaakapp-internal/proxy-lib";
import type { QueryProblem } from "@yaakapp-internal/ui/editor";
import { asOneTerm } from "./query";
import { rpc } from "./rpc";
/** How long values read for completion are offered before they are read again */
const VALUES_MS = 10_000;
const seenValues = new Map<string, { values: string[]; at: number }>();
const reading = new Map<string, Promise<string[]>>();
/**
* The values the proxy has seen for field paths, for completing them: read from the database
* the first time each is asked for, and again once they are a few seconds old, so the list
* keeps up with the traffic without a read per keystroke.
*/
export async function loadSeenValues(paths: string[]): Promise<Record<string, string[]>> {
const now = Date.now();
const entries = await Promise.all(
paths.map(async (path): Promise<[string, string[]]> => {
const cached = seenValues.get(path);
if (cached != null && now - cached.at < VALUES_MS) return [path, cached.values];
let read = reading.get(path);
if (read == null) {
read = rpc("query_values", { path })
.then((values) => {
seenValues.set(path, { values, at: Date.now() });
return values;
})
.catch(() => cached?.values ?? [])
.finally(() => reading.delete(path));
reading.set(path, read);
}
return [path, await read];
}),
);
return Object.fromEntries(entries);
}
const PROBLEM_LABELS: Record<QueryDiagnostic["kind"], string> = {
unknownField: "Unknown field",
invalidValue: "Invalid value",
notYet: "Not yet",
};
/** The terms of a search the backend cannot run, as the box underlines them */
export function problemsOf(diagnostics: QueryDiagnostic[]): QueryProblem[] {
return diagnostics.map((d) => ({
from: d.start,
to: d.end,
label: PROBLEM_LABELS[d.kind],
quiet: d.kind === "notYet",
}));
}
export async function searchProblems(text: string): Promise<QueryProblem[]> {
if (text.trim() === "") return [];
return problemsOf(await rpc("validate_query", { query: text }));
}
/**
* Saved searches whose names hold the word being typed, each replacing it with its search. One
* with `OR` at its top goes in parentheses, so it stays one group beside the box's other terms.
*/
export function savedCompletions(saved: SavedQuery[], typed: string): Completion[] {
const needle = typed.toLowerCase();
return saved
.filter((s) => s.name.toLowerCase().includes(needle))
.map((s) => {
const insert = asOneTerm(s.query);
return {
label: s.name,
detail: s.query,
type: "text",
apply: insert,
};
});
}
+96
View File
@@ -0,0 +1,96 @@
import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import { emptyWindow, type FlowWindow } from "./flowWindow";
import { changesFor, FlowEvents } from "./searchChanges";
const none = () => {};
function write(id: string, matches: boolean, key = "k"): ModelPayload {
return {
model: { id, startedAt: "2026-11-07 12:00:00", updatedAt: "2026-11-07 12:00:00" } as Flow,
change: { type: "upsert", created: false },
search: { key, matches },
};
}
describe("changesFor", () => {
test("takes matching flows and drops those that stop matching", () => {
const win: FlowWindow = { ...emptyWindow, flows: [write("a", true).model] };
const kept = changesFor(
"k",
win,
[write("a", false), write("b", true), write("c", false)],
none,
);
expect(kept.map((c) => [c.model.id, c.change.type])).toEqual([
["a", "delete"],
["b", "upsert"],
]);
});
test("a flow that joins and stops matching in one batch leaves again", () => {
const kept = changesFor("k", emptyWindow, [write("a", true), write("a", false)], none);
expect(kept.map((c) => [c.model.id, c.change.type])).toEqual([
["a", "upsert"],
["a", "delete"],
]);
});
test("leaves flows matched against another search to be matched again", () => {
const later: string[] = [];
const kept = changesFor("k", emptyWindow, [write("a", true, "old")], (unknown) =>
later.push(...unknown.map((c) => c.model.id)),
);
expect([kept, later]).toEqual([[], ["a"]]);
});
});
describe("FlowEvents", () => {
test("drops a late answer for a flow a newer event removed, though the window no longer holds it", () => {
const events = new FlowEvents();
const old = write("a", true, "old");
events.arrived([old]);
// Under the new search the flow stops matching and leaves the window...
events.arrived([write("a", false)]);
// ...and the re-check of the old event answers after
expect(events.current([old])).toEqual([]);
});
test("drops a late answer for a flow the sweep removed", () => {
const events = new FlowEvents();
const old = write("a", true, "old");
events.arrived([old]);
events.removed(["a"]);
expect(events.current([old])).toEqual([]);
});
test("keeps an answer no newer event has passed, and one for other flows", () => {
const events = new FlowEvents();
const a = write("a", true, "old");
const b = write("b", true, "old");
events.arrived([a, b]);
events.arrived([write("c", true)]);
expect(events.current([a, b])).toEqual([a, b]);
// Not noted: nothing to judge it by
const unseen = write("d", true, "old");
expect(events.current([unseen])).toEqual([unseen]);
});
test("past what it remembers, drops what it can no longer judge", () => {
const events = new FlowEvents(2);
const a = write("a", true, "old");
events.arrived([a, write("b", true), write("c", true)]);
expect(events.current([a])).toEqual([]);
const c = write("c", true, "old");
events.arrived([c]);
expect(events.current([c])).toEqual([c]);
});
test("drops answers from before a clear", () => {
const events = new FlowEvents();
const a = write("a", true, "old");
events.arrived([a]);
events.reset();
expect(events.current([a])).toEqual([]);
});
});
+105
View File
@@ -0,0 +1,105 @@
import type { ModelPayload } from "@yaakapp-internal/proxy-lib";
import type { FlowWindow } from "./flowWindow";
/**
* The changes a search's window `win` takes: flows the backend matched against the search, by
* its `key`, as it sent their events, and every deletion. A flow the window holds that no longer
* matches, such as a pending flow that completed under `@is:pending`, leaves it, even when it
* joined earlier in the same batch. Flows matched against another search, sent as the search
* changed or read from the database while another process owns the engine, go to `unknown` to
* be matched again.
*/
export function changesFor(
key: string,
win: FlowWindow,
changes: ModelPayload[],
unknown: (changes: ModelPayload[]) => void,
): ModelPayload[] {
const others: ModelPayload[] = [];
// What the window holds as each change applies
const held = new Set(win.flows.map((f) => f.id));
const kept: ModelPayload[] = [];
for (const c of changes) {
if (c.change.type === "delete") {
held.delete(c.model.id);
kept.push(c);
} else if (c.search?.key !== key) {
others.push(c);
} else if (c.search.matches) {
held.add(c.model.id);
kept.push(c);
} else if (held.delete(c.model.id)) {
kept.push({ ...c, change: { type: "delete" } });
}
}
if (others.length > 0) unknown(others);
return kept;
}
/** Flows whose newest event `FlowEvents` remembers, at most */
const REMEMBERED = 20_000;
/**
* The order flow events arrived in, so an answer that comes later than newer events for the
* same flow can be told apart: a re-check started before the search changed, say, whose flow a
* newer event has since updated or removed, even from the window.
*/
export class FlowEvents {
private count = 0;
/** Each event's place, by the payload as it arrived */
private seqs = new WeakMap<ModelPayload, number>();
/** The newest event's place, by flow, removals included, the least recent first */
private latest = new Map<string, number>();
/** Places at or below this may have newer events no longer remembered */
private floor = 0;
constructor(private readonly remembered = REMEMBERED) {}
/** Notes events as they arrive */
arrived(changes: ModelPayload[]) {
for (const c of changes) {
this.count++;
this.seqs.set(c, this.count);
this.note(c.model.id);
}
}
/** Notes flows removed with no event of their own, as the keep-traffic sweep removes them */
removed(ids: string[]) {
for (const id of ids) {
this.count++;
this.note(id);
}
}
private note(id: string) {
// Moved to the end, so the least recent go first
this.latest.delete(id);
this.latest.set(id, this.count);
for (const [oldest, seq] of this.latest) {
if (this.latest.size <= this.remembered) break;
this.latest.delete(oldest);
this.floor = Math.max(this.floor, seq);
}
}
/**
* Of the changes a late answer is for, those no newer event for their flow has passed. One
* from before the oldest event remembered is dropped, as it can't be judged; the next count
* and reload correct the list. One whose arrival was not noted is kept.
*/
current<T extends ModelPayload>(changes: T[]): T[] {
return changes.filter((c) => {
const seq = this.seqs.get(c);
if (seq == null) return true;
const newest = this.latest.get(c.model.id);
return newest == null ? seq > this.floor : seq >= newest;
});
}
/** Forgets everything, as a clear does */
reset() {
this.latest.clear();
this.floor = this.count;
}
}
+38 -13
View File
@@ -1,7 +1,7 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { describe, expect, test } from "vite-plus/test";
import { parseSearch } from "./search";
import { parseQuery } from "@yaakapp-internal/query";
import {
buildSidebarTree,
itemsById,
@@ -83,8 +83,9 @@ describe("buildSidebarTree", () => {
const query = (id: string) => items.get(id)?.query;
expect(query("all")).toBe("");
expect(query("app:fieldwork")).toBe("@app:fieldwork");
// A path row takes in what is under the path.
expect(query("app:fieldwork|path:api.github.com/repos")).toBe(
"@app:fieldwork @host:api.github.com @path:/repos",
"@app:fieldwork @host:api.github.com @path:/repos/*",
);
expect(query("site:github.com")).toBe("@host:*.github.com");
expect(query("system")).toBe("@is:system");
@@ -93,12 +94,12 @@ describe("buildSidebarTree", () => {
expect(query("app:trustd|host:ocsp.apple.com")).toBe(
"@is:system @app:trustd @host:ocsp.apple.com",
);
expect(parseSearch(query("app:trustd") ?? "", true)?.system).toBe(true);
// What each writes, read back, covers the flows the row counts.
// What each writes reads back as the terms it wrote.
for (const item of items.values()) {
const parsed = parseSearch(item.query);
if (parsed == null || item.kind === "group") continue;
if (item.kind === "group" || item.kind === "all") continue;
const parsed = parseQuery(item.query);
expect(parsed, item.id).toBeTruthy();
expect(parsed?.type, item.id).not.toBe("Or");
}
});
});
@@ -164,7 +165,7 @@ describe("programs outside an app", () => {
expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.query).toBe(
"@app:claude @cwd:yaak @host:statsig.anthropic.com",
);
expect(parseSearch(query)).toEqual({ app: ["claude"], cwd: ["yaak"] });
expect(parseQuery(query)?.type).toBe("And");
});
});
@@ -215,12 +216,10 @@ describe("selection and the search text", () => {
pick("app:Slack|site:slack.com"),
]);
expect(text).toBe("@app:fieldwork @host:api.github.com OR @app:Slack @host:*.slack.com");
expect(parseSearch(text)).toEqual({
any: [
{ app: ["fieldwork"], under: ["api.github.com"] },
{ app: ["Slack"], under: ["*.slack.com"] },
],
});
// Two groups, each judged whole
const parsed = parseQuery(text);
expect(parsed?.type).toBe("Or");
expect(parsed?.type === "Or" ? parsed.children.map((c) => c.type) : []).toEqual(["And", "And"]);
});
test("writes the selected rows' queries, OR'd, or nothing for All traffic", () => {
@@ -240,3 +239,29 @@ describe("selection and the search text", () => {
expect(queryInText("", "card")).toBe(false);
});
});
describe("saved searches", () => {
test("follow All traffic, each a row with its count that writes its query", () => {
const saved = [
{ id: "sq_1", name: "Errors", query: "@status:>=400", count: 12 },
{ id: "sq_2", name: "Stripe", query: "@host:*.stripe.com", count: 0 },
];
const root = buildSidebarTree(flows, 4, saved);
expect((root.children ?? []).slice(0, 2).flatMap((c) => shape(c))).toEqual([
"All traffic 4",
"Saved 0",
" Errors 12",
" Stripe 0",
]);
const items = itemsById(root);
expect(items.get("saved:sq_1")).toMatchObject({
kind: "saved",
query: "@status:>=400",
savedId: "sq_1",
});
// Rows, not folders
const group = (root.children ?? []).find((c) => c.item.id === "saved");
expect(group?.children?.every((c) => c.children == null)).toBe(true);
expect(itemsById(buildSidebarTree(flows, 4)).has("saved")).toBe(false);
});
});
+57 -30
View File
@@ -1,20 +1,13 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import type { DecryptSubject } from "./decrypt";
import {
appKeyOf,
folderOf,
formatFieldFilter,
isSystemFlow,
runtimeFolderOf,
sidebarPath,
siteOf,
tokenize,
} from "./search";
import { fieldTerm, pathTerm, siteTerm, termKeys } from "./query";
import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search";
export type SidebarKind =
| "all"
| "tunnelled"
| "saved"
| "group"
| "app"
| "folder"
@@ -23,8 +16,8 @@ export type SidebarKind =
| "path";
/**
* A row of the sidebar: All traffic, a group, an app, a program's working directory, or a site,
* host or path under one
* A row of the sidebar: All traffic, a saved search, a group, an app, a program's working
* directory, or a site, host or path under one
*/
export interface SidebarItem {
id: string;
@@ -42,10 +35,21 @@ export interface SidebarItem {
query: string;
/** What Decrypt in the row's menu decrypts: its host, site or app */
decrypt?: DecryptSubject;
/** The saved search a Saved row is, for renaming and deleting it */
savedId?: string;
}
/** A saved search as the sidebar lists it, with how many flows it covers */
export interface SavedRow {
id: string;
name: string;
query: string;
count: number;
}
/** The ids of All traffic and the groups */
export const ALL_ID = "all";
export const SAVED_ID = "saved";
export const APPS_ID = "apps";
export const DOMAINS_ID = "domains";
export const SYSTEM_ID = "system";
@@ -83,13 +87,13 @@ function placeOf(flow: Flow): Place {
const [host = "", ...segments] = schemeless ? [path] : path.split("/");
const site = siteOf(host);
const app = appKeyOf(flow);
const hostQuery = formatFieldFilter("host", host);
const hostQuery = fieldTerm("host", host);
const rows: PlaceRow[] = [
{
id: `site:${site}`,
kind: "site",
label: site,
query: formatFieldFilter("host", `*.${site}`),
query: siteTerm(site),
decrypt: { kind: "site", site },
},
{
@@ -107,7 +111,7 @@ function placeOf(flow: Flow): Place {
id: `path:${host}${p}`,
kind: "path",
label: `/${segment}`,
query: `${hostQuery} ${formatFieldFilter("path", p)}`,
query: `${hostQuery} ${pathTerm(p)}`,
});
}
const system = isSystemFlow(flow);
@@ -123,7 +127,7 @@ function placeOf(flow: Flow): Place {
}));
} else {
const folderId = `app:${app}|cwd:${folder}`;
const folderQuery = `${appQuery} ${formatFieldFilter("cwd", folder)}`;
const folderQuery = `${appQuery} ${fieldTerm("cwd", folder)}`;
appRows = [
{ id: folderId, kind: "folder", label: folder, query: folderQuery },
...rows.map((r) => ({
@@ -144,8 +148,8 @@ function placeOf(flow: Flow): Place {
* while System is hidden from All traffic.
*/
function appQueryFor(app: string, system: boolean): string {
const query = formatFieldFilter("app", app);
return system ? `${formatFieldFilter("is", "system")} ${query}` : query;
const query = fieldTerm("app", app);
return system ? `${fieldTerm("is", "system")} ${query}` : query;
}
/** A node being built, its children by id */
@@ -209,10 +213,14 @@ function group(id: string, label: string): Trie {
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
* nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through
* undecrypted, when there is any. `total` is All traffic's count: every flow it covers in the
* database.
* undecrypted, when there is any, and Saved lists the saved searches, each with its count from
* the database. `total` is All traffic's count: every flow it covers in the database.
*/
export function buildSidebarTree(flows: Flow[], total: number): TreeNode<SidebarItem> {
export function buildSidebarTree(
flows: Flow[],
total: number,
saved: SavedRow[] = [],
): TreeNode<SidebarItem> {
const root: TreeNode<SidebarItem> = {
item: { id: "root", kind: "group", label: "", count: 0, query: "" },
parent: null,
@@ -223,7 +231,7 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode<Sidebar
const apps = group(APPS_ID, "Apps");
const domains = group(DOMAINS_ID, "Domains");
const system = { ...group(SYSTEM_ID, "System") };
system.item.query = formatFieldFilter("is", "system");
system.item.query = fieldTerm("is", "system");
let tunnelled = 0;
for (const flow of flows) {
@@ -273,13 +281,36 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode<Sidebar
kind: "tunnelled",
label: "Not decrypted",
count: tunnelled,
query: formatFieldFilter("is", "tunnelled"),
query: fieldTerm("is", "tunnelled"),
},
parent: root,
depth: 1,
draggable: false,
});
}
if (saved.length > 0) {
const node: TreeNode<SidebarItem> = {
item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" },
parent: root,
depth: 1,
children: [],
draggable: false,
};
node.children = saved.map((s) => ({
item: {
id: `saved:${s.id}`,
kind: "saved",
label: s.name,
count: s.count,
query: s.query,
savedId: s.id,
},
parent: node,
depth: 2,
draggable: false,
}));
root.children?.push(node);
}
for (const g of [apps, domains, system]) {
if (g.children.size === 0) continue;
const node: TreeNode<SidebarItem> = {
@@ -318,12 +349,8 @@ export function selectionText(items: SidebarItem[]): string {
* stays selected while it is, so typing after selecting keeps it.
*/
export function queryInText(query: string, text: string): boolean {
const have = new Set(tokenize(text).map(tokenKey));
const want = tokenize(query);
const have = new Set(termKeys(text));
const want = termKeys(query);
if (want.length === 0) return have.size === 0;
return want.every((t) => have.has(tokenKey(t)));
}
function tokenKey(t: { field: string | null; value: string; negated: boolean }): string {
return `${t.negated ? "-" : ""}${t.field ?? ""}:${t.value.toLowerCase()}`;
return want.every((t) => have.has(t));
}
+143 -16
View File
@@ -1,4 +1,9 @@
import type { Flow, GetProxyStateResponse, ModelPayload } from "@yaakapp-internal/proxy-lib";
import type {
Flow,
GetProxyStateResponse,
ModelPayload,
SavedQuery,
} from "@yaakapp-internal/proxy-lib";
import { selectedIdsFamily } from "@yaakapp-internal/ui";
import { atom, type createStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
@@ -20,8 +25,9 @@ import {
WINDOW_SIZE,
windowFromPage,
} from "./flowWindow";
import { activeQuery, anyCountsBackFromNow, countsBackFromNow, type FlowQuery } from "./query";
import { listen, rpc } from "./rpc";
import { type FlowQuery, matchesQuery, parseSearch } from "./search";
import { changesFor, FlowEvents } from "./searchChanges";
import {
ALL_ID,
APPS_ID,
@@ -29,6 +35,8 @@ import {
DOMAINS_ID,
itemsById,
queryInText,
SAVED_ID,
type SavedRow,
selectionText,
} from "./sidebarTree";
@@ -40,6 +48,8 @@ const FLUSH_MS = 100;
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;
@@ -90,9 +100,26 @@ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, unde
/** The search the list shows, from the search box's settled text, or null for all traffic */
export const activeQueryAtom = atom<FlowQuery | null>((get) =>
parseSearch(get(searchTermsAtom), get(hideSystemAtom)),
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);
@@ -119,7 +146,7 @@ const SIDEBAR_MS = 1000;
/** The sidebar's rows, from the flows loaded */
export const sidebarTreeAtom = atom((get) =>
buildSidebarTree(get(sidebarFlowsAtom), get(allTrafficTotalAtom)),
buildSidebarTree(get(sidebarFlowsAtom), get(allTrafficTotalAtom), get(savedRowsAtom)),
);
const storedColumnPrefsAtom = atomWithStorage<unknown>(
@@ -244,7 +271,7 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
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 : changesFor(search.query, changes);
const kept = search == null ? changes : changesOf(search, merged, changes);
// Counted when they first applied.
return { ...applyChanges(merged, kept), total: merged.total };
};
@@ -261,9 +288,47 @@ export const loadOlderFlowsAtom = atom(null, async (get, set) => {
}
});
/** The changes a search's window takes: flows it matches, and every deletion */
function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] {
return changes.filter((c) => c.change.type === "delete" || matchesQuery(c.model, query));
/** `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;
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));
}
/**
@@ -274,6 +339,7 @@ function changesFor(query: FlowQuery, changes: ModelPayload[]): ModelPayload[] {
* way.
*/
export function syncFlows(store: Store) {
syncedStore = store;
let pending: ModelPayload[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
let loaded = false;
@@ -297,7 +363,7 @@ export function syncFlows(store: Store) {
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, changesFor(s.query, changes));
const next = applyChanges(s.win, changesOf(s, s.win, changes));
return { ...s, win: following ? trimWindow(next) : next };
});
const selected = store.get(selectedFlowAtom);
@@ -309,6 +375,7 @@ export function syncFlows(store: Store) {
}
function receive(changes: ModelPayload[]) {
flowEvents.arrived(changes);
pending.push(...changes);
if (timer == null) timer = setTimeout(flush, FLUSH_MS);
}
@@ -326,10 +393,11 @@ export function syncFlows(store: Store) {
);
});
if (store.get(hideSystemAtom)) {
void rpc("list_flows", { system: false, limit: 0 }).then((res) => {
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) => {
@@ -340,21 +408,60 @@ export function syncFlows(store: Store) {
});
}
// 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))
@@ -399,21 +506,31 @@ export function syncFlows(store: Store) {
}
reloadFlows = load;
/** Loads the first page of `query`'s flows into a window of its own, or drops it for null */
function loadSearch(query: FlowQuery | null) {
/**
* 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)}`;
store.set(searchWindowAtom, { key, query, win: emptyWindow, loading: true, buffered: [] });
// 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 win = applyChanges(page ?? s.win, changesFor(query, s.buffered));
const base = page ?? s.win;
const win = applyChanges(base, changesOf(s, base, s.buffered));
return {
...s,
win: page == null ? win : { ...win, total: page.total },
@@ -451,11 +568,21 @@ export function syncFlows(store: Store) {
}
});
// All traffic's count leaves System out while it is hidden.
// 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);
@@ -576,7 +703,7 @@ export function syncSidebarSelection(store: Store) {
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);
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) => {
+1
View File
@@ -35,6 +35,7 @@
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^1.4.0"
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [react()],
// The search box's query language is a WebAssembly package
plugins: [wasm(), react()],
build: {
// Top-level await, which the WebAssembly package loads with
target: "esnext",
outDir: "../../dist/apps/yaak-proxy",
emptyOutDir: true,
},