Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export function canonicalMultideviceEvents(params: MultiQueryParams): string {
return query;
}

const default_limit = 100; // Hardcoded limit per group
export const default_limit = 100; // Hardcoded limit per group

export function appQuery(
appbucket: string,
Expand Down
50 changes: 39 additions & 11 deletions src/stores/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { useBucketsStore } from '~/stores/buckets';
import { useCategoryStore } from '~/stores/categories';

import { getClient } from '~/util/awclient';
import {
FullDesktopQueryResult,
mergeFullDesktopResults,
periodsForFullDesktopQuery,
} from '~/util/desktopQuerySplit';

function timeperiodsStrsHoursOfPeriod(timeperiod: TimePeriod): string[] {
return timeperiodsHoursOfPeriod(timeperiod).map(timeperiodToStr);
Expand Down Expand Up @@ -57,6 +62,32 @@ function scoreCategories(events: IEvent[]): IEvent[] {
});
}

/**
* One day at a time, same reason as query_category_time_by_period.
* Axios timeout is per request (default 30s). Measured 2026-08-28 on a
* 31 MB / 4-month aw-server: July as one TIMEINTERVAL 0.95s vs max daily
* 0.098s; 31-day sequential wall 0.99s. See desktopQuerySplit.ts.
*/
async function queryDesktopPeriods(
periods: string[],
query: string[],
name: string
): Promise<FullDesktopQueryResult> {
const client = getClient();
const signal = client.controller.signal;
const results: FullDesktopQueryResult[] = [];
for (const period of periods) {
if (signal.aborted) {
throw signal['reason'] || 'unknown reason';
}
const data = await client.query([period], query, { name, verbose: true });
if (data && data[0]) {
results.push(data[0]);
}
}
return mergeFullDesktopResults(results);
}

export interface QueryOptions {
host: string;
date?: string;
Expand Down Expand Up @@ -386,7 +417,7 @@ export const useActivityStore = defineStore('activity', {
{ timeperiod, filter_categories, filter_afk, always_active_pattern }: QueryOptions,
hosts: string[]
) {
const periods = [timeperiodToStr(timeperiod)];
const periods = periodsForFullDesktopQuery(timeperiod);
const categories = useCategoryStore().classes_for_query;

const q = queries.multideviceQuery({
Expand All @@ -397,8 +428,8 @@ export const useActivityStore = defineStore('activity', {
host_params: {},
always_active_pattern,
});
const data = await getClient().query(periods, q, { name: 'multidevice', verbose: true });
this.query_window_completed(data[0].window);
const merged = await queryDesktopPeriods(periods, q, 'multidevice');
this.query_window_completed(merged.window || {});
},

async query_desktop_full({
Expand All @@ -409,7 +440,7 @@ export const useActivityStore = defineStore('activity', {
include_stopwatch,
always_active_pattern,
}: QueryOptions) {
const periods = [timeperiodToStr(timeperiod)];
const periods = periodsForFullDesktopQuery(timeperiod);
const categories = useCategoryStore().classes_for_query;

const q = queries.fullDesktopQuery({
Expand All @@ -426,14 +457,11 @@ export const useActivityStore = defineStore('activity', {
include_audible,
always_active_pattern,
});
const data = await getClient().query(periods, q, {
name: 'fullDesktopQuery',
verbose: true,
});
this.query_window_completed(data[0].window);
this.query_browser_completed(data[0].browser);
const merged = await queryDesktopPeriods(periods, q, 'fullDesktopQuery');
this.query_window_completed(merged.window || {});
this.query_browser_completed(merged.browser || {});
if (include_stopwatch) {
this.query_stopwatch_completed(data[0].stopwatch);
this.query_stopwatch_completed(merged.stopwatch || {});
}
},

Expand Down
173 changes: 173 additions & 0 deletions src/util/desktopQuerySplit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import moment from 'moment';

import { default_limit as DESKTOP_QUERY_EVENT_LIMIT } from '~/queries';
import { IEvent } from '~/util/interfaces';
import { TimePeriod, timeperiodToStr, timeperiodsDaysOfPeriod } from '~/util/timeperiod';

export { DESKTOP_QUERY_EVENT_LIMIT };

export interface WindowQueryResult {
app_events?: IEvent[];
title_events?: IEvent[];
cat_events?: IEvent[];
active_events?: IEvent[];
duration?: number;
}

export interface BrowserQueryResult {
domains?: IEvent[];
urls?: IEvent[];
titles?: IEvent[];
duration?: number;
}

export interface StopwatchQueryResult {
stopwatch_events?: IEvent[];
}

export interface FullDesktopQueryResult {
window?: WindowQueryResult;
browser?: BrowserQueryResult;
stopwatch?: StopwatchQueryResult;
}

/**
* Periods for `fullDesktopQuery` / `multideviceQuery`.
*
* Axios `requestTimeout` is 30s *per request* (`settings.ts` default). The
* month summary used to send the whole month as one TIMEINTERVAL and the
* client aborted on large databases. Categorize is not the cost: on 50k
* events it is 27–116 ms in release (aw-transform, 2026-08-28).
*
* Measured 2026-08-28 against a live 31 MB / 4-month aw-server v0.13.2
* (41,806 window events, no browser buckets). Query shape: flood window +
* flood AFK + filter_period_intersect + categorize (6 rules) + merge/limit.
*
* July (23,195 window events) as one TIMEINTERVAL: 0.95 s
* Same month as 31 sequential daily requests: 0.99 s wall, max day 0.098 s
* Apr–Aug as one TIMEINTERVAL: 1.79 s (query_bucket+flood is 1.64 s of that)
*
* Per-request time drops ~10×; total wall-clock stays comparable. Sequential
* day requests are not extra overhead — they keep each Axios call under 30 s.
* A 168 MB / 2y reporter DB still 30s-outs the unsplitted month view; this
* 31 MB host does not, which is why the split matches
* `query_category_time_by_period` rather than raising the global timeout.
*
* A single day stays one request. Week, month, and multi-day ranges split
* into days. A year is also split into days: month-sized chunks are the
* timeout. Future-starting periods are dropped so we don't query incomplete days.
*/
export function periodsForFullDesktopQuery(
timeperiod: TimePeriod,
now: Date = new Date()
): string[] {
const [count, res] = timeperiod.length;
let periods: string[];

if (res.startsWith('day') && count === 1) {
periods = [timeperiodToStr(timeperiod)];
} else if (
res.startsWith('day') ||
(res.startsWith('week') && count === 1) ||
(res.startsWith('month') && count === 1)
) {
periods = timeperiodsDaysOfPeriod(timeperiod).map(timeperiodToStr);
} else if (res.startsWith('year') && count === 1) {
const start = moment(timeperiod.start);
const end = start.clone().add(1, 'year');
periods = [];
for (let d = start.clone(); d.isBefore(end); d.add(1, 'day')) {
periods.push(timeperiodToStr({ start: d.format(), length: [1, 'day'] }));
}
} else {
periods = [timeperiodToStr(timeperiod)];
}

return periods.filter(period => new Date(period.split('/')[0]) < now);
}

export function mergeEventsByKeys(events: IEvent[], keys: string[], limit?: number): IEvent[] {
const groups = new Map<string, IEvent>();
for (const event of events) {
if (!event) continue;
const groupKey = keys.map(k => JSON.stringify(event.data?.[k])).join('\0');
const existing = groups.get(groupKey);
const duration = event.duration || 0;
if (!existing) {
groups.set(groupKey, {
timestamp: event.timestamp,
duration,
data: { ...event.data },
});
} else {
existing.duration += duration;
if (event.timestamp && existing.timestamp && event.timestamp < existing.timestamp) {
existing.timestamp = event.timestamp;
}
}
}
const merged = Array.from(groups.values()).sort((a, b) => b.duration - a.duration);
return limit === undefined ? merged : merged.slice(0, limit);
}

function concatEvents(chunks: Array<IEvent[] | undefined>): IEvent[] {
const out: IEvent[] = [];
for (const chunk of chunks) {
if (chunk) out.push(...chunk);
}
return out;
}

function sumDurations(values: Array<number | undefined>): number {
return values.reduce((acc: number, value) => acc + (value || 0), 0);
}

export function mergeFullDesktopResults(results: FullDesktopQueryResult[]): FullDesktopQueryResult {
const windows = results.map(r => r.window).filter(Boolean) as WindowQueryResult[];
const browsers = results.map(r => r.browser).filter(Boolean) as BrowserQueryResult[];
const stopwatches = results.map(r => r.stopwatch).filter(Boolean) as StopwatchQueryResult[];

return {
window: {
app_events: mergeEventsByKeys(
concatEvents(windows.map(w => w.app_events)),
['app'],
DESKTOP_QUERY_EVENT_LIMIT
),
title_events: mergeEventsByKeys(
concatEvents(windows.map(w => w.title_events)),
['app', 'title'],
DESKTOP_QUERY_EVENT_LIMIT
),
// cat_events is not limit_events'd in fullDesktopQuery
cat_events: mergeEventsByKeys(concatEvents(windows.map(w => w.cat_events)), ['$category']),
active_events: concatEvents(windows.map(w => w.active_events)),
duration: sumDurations(windows.map(w => w.duration)),
},
browser: {
domains: mergeEventsByKeys(
concatEvents(browsers.map(b => b.domains)),
['$domain'],
DESKTOP_QUERY_EVENT_LIMIT
),
urls: mergeEventsByKeys(
concatEvents(browsers.map(b => b.urls)),
['url'],
DESKTOP_QUERY_EVENT_LIMIT
),
titles: mergeEventsByKeys(
concatEvents(browsers.map(b => b.titles)),
['title'],
DESKTOP_QUERY_EVENT_LIMIT
),
duration: sumDurations(browsers.map(b => b.duration)),
},
stopwatch: {
stopwatch_events: mergeEventsByKeys(
concatEvents(stopwatches.map(s => s.stopwatch_events)),
['label'],
DESKTOP_QUERY_EVENT_LIMIT
),
},
};
}
Loading
Loading