Skip to content

perf(desktop): paginate usage activity - #4539

Open
Phoenix500526 wants to merge 1 commit into
apache:mainfrom
Phoenix500526:fix/usage-activity-log-performance
Open

perf(desktop): paginate usage activity#4539
Phoenix500526 wants to merge 1 commit into
apache:mainfrom
Phoenix500526:fix/usage-activity-log-performance

Conversation

@Phoenix500526

@Phoenix500526 Phoenix500526 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The slowdown came from Settings → Usage → Activity log rendering every
matching record whenever the tab was opened. With hundreds of records, React
rebuilt the entire table—including per-row tooltips and actions—on every visit.

Paginate the log at 50 records per page so only the visible rows are mounted.
Searching, changing status, or clearing filters returns the table to the first
page.

Fixes #4531

Verification

  • npm --workspace @maka/desktop run typecheck
  • npm exec -- biome lint on the changed TypeScript files
  • npm run format:check (1,857 files)
  • npm run check:renderer-architecture -- --base upstream/main (61 tests)
  • npm --workspace @maka/desktop run build-storybook
  • npm --workspace @maka/desktop run smoke:storybook (502 renders)
  • Deterministic 409-record render benchmark, 10 alternating runs per variant
    on the same build: all rows median 1,616.9 ms / p95 1,670.2 ms; 50 per page
    median 74.0 ms / p95 80.0 ms; 21.9× faster by median

The benchmark measures Activity log rendering only; it does not include
loading the usage history from the backend.

UI

Before Change

image

After Change

image

Benchmark result

Save this snippet as /tmp/maka-usage-benchmark.cjs, then run it from a Maka
checkout with dependencies and Playwright Chromium installed.

/* benchmark.cjs */
const { execFileSync } = require('node:child_process');
const { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join } = require('node:path');
const { pathToFileURL } = require('node:url');

const repo = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
const temp = mkdtempSync(join(tmpdir(), 'maka-usage-benchmark-'));
const env = { ...process.env, CACHE_DIR: join(temp, 'cache') };
const run = (command, args, cwd) => execFileSync(command, args, { cwd, env, stdio: 'pipe' });
const { chromium } = require(require.resolve('playwright', { paths: [repo] }));
const vite = pathToFileURL(require.resolve('vite', { paths: [repo] }));

function patch(file, before, after) {
  const source = readFileSync(file, 'utf8');
  if (!source.includes(before)) throw new Error(`Benchmark fixture is outdated: ${before}`);
  writeFileSync(file, source.replace(before, after));
}

function prepare() {
  const source = join(temp, 'source');
  const archive = join(temp, 'source.tar');
  mkdirSync(source);
  run('npm', ['--workspace', '@maka/desktop', 'run', 'build:workspace-deps'], repo);
  run('git', ['archive', `--output=${archive}`, 'HEAD'], repo);
  run('tar', ['-xf', archive, '-C', source], repo);
  symlinkSync(join(repo, 'node_modules'), join(source, 'node_modules'), 'dir');
  const nestedModules = join(source, 'apps/desktop/node_modules');
  mkdirSync(join(nestedModules, '@vitejs'), { recursive: true });
  symlinkSync(join(repo, 'apps/desktop/node_modules/@vitejs/plugin-react'),
    join(nestedModules, '@vitejs/plugin-react'), 'dir');
  patch(join(source, 'apps/desktop/src/renderer/settings/usage-settings-page.tsx'),
    'pageSize={USAGE_REQUESTS_PAGE_SIZE}',
    "pageSize={Number(Reflect.get(globalThis, '__usageBenchmarkPageSize')) || USAGE_REQUESTS_PAGE_SIZE}");
  run('npm', ['run', 'sync:model-metadata'], source);
  run('npm', ['--workspace', '@maka/desktop', 'run', 'build-storybook'], source);
  return join(source, 'apps/desktop/storybook-static');
}

function waitForTable(page, columns, rows) {
  return page.waitForFunction(({ columns, rows }) => [...document.querySelectorAll('table')].some(
    (table) => table.querySelectorAll('thead th').length === columns
      && table.querySelectorAll('tbody tr').length === rows,
  ), { columns, rows }, { timeout: 10_000 });
}

async function setup(page, url) {
  await page.goto(url, { waitUntil: 'networkidle' });
  await page.waitForFunction(() => window.maka?.settings?.usageStats);
  await page.evaluate(async () => {
    const stats = await window.maka.settings.usageStats();
    const logs = Array.from({ length: 409 }, (_, index) => ({
      id: String(index), ts: 1_800_000_000_000 - index * 60_000, kind: 'model',
      sessionId: `session-${index}`, sessionName: `Benchmark ${index}`,
      provider: 'openai', model: 'gpt-5', inputTokens: 12_400,
      outputTokens: 3_800, costUsd: 0.04, latencyMs: 1_000, status: 'success',
    }));
    window.maka.settings.usageStats = async () => ({
      ...stats, logs, summary: { ...stats.summary, totalRequests: logs.length },
    });
  });
  await page.getByRole('button', { name: 'Refresh usage' }).click();
  await page.getByRole('button', { name: /^Activity log/ }).click();
  await page.getByRole('button', { name: 'Show details' }).click();
  await page.getByRole('button', { name: /^Providers/ }).click();
  await waitForTable(page, 4, 1);
}

async function renderActivity(page, rows) {
  await page.evaluate((size) => Reflect.set(globalThis, '__usageBenchmarkPageSize', size), rows);
  const elapsed = await page.evaluate(async (expected) => {
    const button = [...document.querySelectorAll('button')]
      .find((item) => item.textContent?.trim().startsWith('Activity log'));
    const start = performance.now();
    button.click();
    while (performance.now() - start < 10_000) {
      const table = [...document.querySelectorAll('table')]
        .find((item) => item.querySelectorAll('thead th').length === 8);
      if (table?.querySelectorAll('tbody tr').length === expected) {
        void table.getBoundingClientRect();
        return performance.now() - start;
      }
      await new Promise(requestAnimationFrame);
    }
    throw new Error(`Expected ${expected} Activity Log rows.`);
  }, rows);
  await page.getByRole('button', { name: /^Providers/ }).click();
  await waitForTable(page, 4, 1);
  return elapsed;
}

function summarize(values) {
  const sorted = [...values].sort((left, right) => left - right);
  const middle = sorted.length / 2;
  return {
    median: (sorted[middle - 1] + sorted[middle]) / 2,
    p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
  };
}

(async () => {
  let browser;
  let server;
  try {
    const staticDir = prepare();
    const { preview } = await import(vite);
    server = await preview({ configFile: false, root: join(staticDir, '..'),
      build: { outDir: 'storybook-static' }, logLevel: 'silent',
      preview: { host: '127.0.0.1', port: 0 } });
    const url = `http://127.0.0.1:${server.httpServer.address().port}/iframe.html?id=product-settings-pages--usage-single-provider&viewMode=story&globals=locale:en`;
    browser = await chromium.launch({ headless: true });
    const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, timezoneId: 'UTC' });
    await setup(page, url);
    await renderActivity(page, 409);
    await renderActivity(page, 50);
    const all = [];
    const paginated = [];
    for (let run = 0; run < 10; run += 1) {
      const variants = run % 2 ? [[50, paginated], [409, all]] : [[409, all], [50, paginated]];
      for (const [rows, times] of variants) times.push(await renderActivity(page, rows));
    }
    const allRows = summarize(all);
    const fiftyRows = summarize(paginated);
    console.log(`All rows: median ${allRows.median.toFixed(1)} ms, p95 ${allRows.p95.toFixed(1)} ms`);
    console.log(`50/page:  median ${fiftyRows.median.toFixed(1)} ms, p95 ${fiftyRows.p95.toFixed(1)} ms`);
    console.log(`Speedup:  ${(allRows.median / fiftyRows.median).toFixed(1)}x by median`);
  } finally {
    await browser?.close();
    await server?.close();
    rmSync(temp, { recursive: true, force: true });
  }
})().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});
$ cd maka-agent
$ node /tmp/maka-usage-benchmark.cjs
All rows: median 1616.9 ms, p95 1670.2 ms
50/page:  median 74.0 ms, p95 80.0 ms
Speedup:  21.9x by median

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex assisted with implementation, tests,
benchmarking, review, and verification.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 2, 2026
@Phoenix500526
Phoenix500526 force-pushed the fix/usage-activity-log-performance branch from 15e250d to 0bb0081 Compare September 2, 2026 06:15
@Phoenix500526

Copy link
Copy Markdown
Contributor Author

CC @liuxiaocs7

@liuxiaocs7

Copy link
Copy Markdown
Member

Thanks for working on this. The core pagination behavior appears to address #4531, and CI is green. I found the following issues before merge:

Standards

  1. [P2] The new UI implementation bypasses the Astryx surface inventory.

    apps/desktop/src/renderer/features/usage-activity/index.tsx contains a stateful UI component, but the inventory generator excludes every index.tsx as a “barrel re-export.” Consequently, docs/astryx-surface-file-inventory.md excludes this real surface from its coverage gate. Please move the implementation to a file such as ui/usage-activity-pagination.tsx and keep index.ts as a true public barrel, or update the generator so non-barrel index files are inspected.

  2. [P2] Preserve full-dataset row semantics for assistive technology.

    The activity table now receives only the current page, but it does not pass Astryx Table's rowIndexStart and rowCount metadata. On page 2, assistive technology therefore sees a new one-row table rather than row 51 of 51. Please pass the page offset and total filtered count through to UsageStatsTable, and add a focused ARIA assertion.

  3. [P3] The reset behavior promised by the PR is not covered by tests.

    The updated story verifies navigation to page 2, but it does not verify that searching, changing status, or clearing filters returns the table to page 1. Please exercise these paths starting from page 2 and assert that first-page rows are shown.

  4. [P3] The PR is missing UI evidence.

    CONTRIBUTING.md asks UI changes to include before/after screenshots or a recording. Please add one showing the paginated activity log, including the narrow layout if possible.

  5. [P3] Prefer the public UI entry for shared primitives.

    HStack is imported directly from @astryxdesign/core even though it is re-exported by @maka/ui. The renderer guidance says new code should reach for Astryx-backed @maka/ui primitives first. Pagination and paginateData can remain direct imports if they are not exposed there.

Spec / verification

  1. [P3] The benchmark does not follow the method requested in perf(desktop): Usage activity log is slow with large histories #4531.

    The issue asks for at least 10 switches on both revisions and comparison of median and p95. The supplied script records five timed samples per variant and reports only the average. The summary values (1438.4 ms / 69.3 ms) also differ from the pasted output (1480.8 ms / 72.1 ms). Please rerun and report the requested statistics, or reconcile the documented methodology and results.

@Phoenix500526
Phoenix500526 force-pushed the fix/usage-activity-log-performance branch from 0bb0081 to 22d229c Compare September 2, 2026 10:30
Large usage histories rebuilt every activity row on each tab visit.
Limiting each page to 50 records keeps repeated navigation responsive.

CLOSES apache#4531
Generated-by: OpenAI Codex

Signed-off-by: Jiawei Zhao <Phoenix500526@163.com>
@Phoenix500526
Phoenix500526 force-pushed the fix/usage-activity-log-performance branch from 22d229c to 201f380 Compare September 2, 2026 10:36
@Phoenix500526

Copy link
Copy Markdown
Contributor Author

Thanks for working on this. The core pagination behavior appears to address #4531, and CI is green. I found the following issues before merge:

Standards

  1. [P2] The new UI implementation bypasses the Astryx surface inventory.
    apps/desktop/src/renderer/features/usage-activity/index.tsx contains a stateful UI component, but the inventory generator excludes every index.tsx as a “barrel re-export.” Consequently, docs/astryx-surface-file-inventory.md excludes this real surface from its coverage gate. Please move the implementation to a file such as ui/usage-activity-pagination.tsx and keep index.ts as a true public barrel, or update the generator so non-barrel index files are inspected.
  2. [P2] Preserve full-dataset row semantics for assistive technology.
    The activity table now receives only the current page, but it does not pass Astryx Table's rowIndexStart and rowCount metadata. On page 2, assistive technology therefore sees a new one-row table rather than row 51 of 51. Please pass the page offset and total filtered count through to UsageStatsTable, and add a focused ARIA assertion.
  3. [P3] The reset behavior promised by the PR is not covered by tests.
    The updated story verifies navigation to page 2, but it does not verify that searching, changing status, or clearing filters returns the table to page 1. Please exercise these paths starting from page 2 and assert that first-page rows are shown.
  4. [P3] The PR is missing UI evidence.
    CONTRIBUTING.md asks UI changes to include before/after screenshots or a recording. Please add one showing the paginated activity log, including the narrow layout if possible.
  5. [P3] Prefer the public UI entry for shared primitives.
    HStack is imported directly from @astryxdesign/core even though it is re-exported by @maka/ui. The renderer guidance says new code should reach for Astryx-backed @maka/ui primitives first. Pagination and paginateData can remain direct imports if they are not exposed there.

Spec / verification

  1. [P3] The benchmark does not follow the method requested in perf(desktop): Usage activity log is slow with large histories #4531.
    The issue asks for at least 10 switches on both revisions and comparison of median and p95. The supplied script records five timed samples per variant and reports only the average. The summary values (1438.4 ms / 69.3 ms) also differ from the pasted output (1480.8 ms / 72.1 ms). Please rerun and report the requested statistics, or reconcile the documented methodology and results.

Done

@Phoenix500526

Copy link
Copy Markdown
Contributor Author

@liuxiaocs7 The latest CI run failed on the unrelated partial-history-notice E2E after 100 other E2E tests passed; this PR does not touch that surface, so it appears to be a timing flake. I do not have permission to rerun Actions—could you please rerun the failed job when convenient? Thanks!

@liuxiaocs7

Copy link
Copy Markdown
Member

Thank you for the analysis. However, I don’t have permission to rerun the CI checks. Could you please push an empty commit to trigger CI again?

@liuxiaocs7 liuxiaocs7 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(desktop): Usage activity log is slow with large histories

2 participants