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
52 changes: 52 additions & 0 deletions app/src/__tests__/analog-inspector-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,55 @@ describe('Analog open pages', () => {
expect(inspector.isOpen(route('/src/app/pages/about.page.ts'))).toBe(false);
});
});

describe('Analog server calls of the tab', () => {
afterEach(() => {
scopeToPage(null);
document.body.innerHTML = '';
});

const call = (id: number, url: string, pageId?: string) => ({
id,
at: id,
kind: 'load',
method: 'GET',
url,
status: 200,
ms: 1,
from: 'browser',
...(pageId ? { pageId } : {}),
});
const state = {
pages: [page('A', '/a'), page('B', '/b')],
calls: [call(1, '/from-a', 'A'), call(2, '/from-b', 'B'), call(3, '/untagged')],
duplicates: [{ route: '/only-b', ssrAt: 1, browserAt: 2 }],
};

it('shows only the panel tab\'s calls until "All tabs" is ticked', async () => {
scopeToPage('A');
const fixture = await render(client({ state }));
const inspector = fixture.componentInstance;
inspector.view.set('server');
await settle(fixture);
const root: HTMLElement = fixture.nativeElement;
const urls = () => Array.from(root.querySelectorAll('td.request .url'), (e) => e.textContent);
expect(urls()).toEqual(['/from-a']);
// A warning about a route this tab never called stays hidden.
expect(inspector.duplicates()).toEqual([]);

const toggle = root.querySelector<HTMLInputElement>('[data-testid="analog-all-tabs"]')!;
expect(toggle.parentElement?.textContent).toContain('All tabs (including untagged)');
toggle.click();
await settle(fixture);
expect(urls()).toEqual(['/untagged', '/from-b', '/from-a']);
});

it('shows every call when no page reports', async () => {
const fixture = await render(client({ state: { calls: state.calls } }));
fixture.componentInstance.view.set('server');
await settle(fixture);
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelectorAll('td.request .url')).toHaveLength(3);
expect(root.querySelector('[data-testid="analog-all-tabs"]')).toBeNull();
});
});
68 changes: 56 additions & 12 deletions app/src/pages/analog-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ interface AnalogCall {
location?: string;
seeded?: boolean;
preview?: string;
/** The tab the call came from, when the dev server could tell. */
pageId?: string;
}

interface AnalogPage {
Expand Down Expand Up @@ -356,7 +358,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
</div>
<div class="stat">
<span class="label">Server calls</span>
<span class="value">{{ allCalls().length }}</span>
<span class="value">{{ tabCalls().length }}</span>
</div>
<div class="stat" [attr.data-tone]="lintTone() || 'good'">
<span class="label">Issues</span>
Expand Down Expand Up @@ -626,8 +628,19 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
/>
Failed only
</label>
@if (scopePageId()) {
<label class="check">
<input
type="checkbox"
data-testid="analog-all-tabs"
[checked]="allTabs()"
(change)="allTabs.set($any($event.target).checked)"
/>
All tabs (including untagged)
</label>
}
<span class="total" aria-live="polite"
>{{ matchingCalls().length }} of {{ allCalls().length }}</span
>{{ matchingCalls().length }} of {{ tabCalls().length }}</span
>
</div>
@if (shownCount() < matchingCalls().length || serverFull()) {
Expand Down Expand Up @@ -720,14 +733,14 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
</tbody>
</table>
</div>
} @else if (allCalls().length && callFiltered()) {
} @else if (tabCalls().length && callFiltered()) {
<div class="empty-box no-match" role="status">
<p class="empty-lead">No calls match.</p>
<p class="muted">
@if (callQuery().trim()) {
Nothing matches “{{ callQuery().trim() }}” with these filters.
} @else {
None of the {{ allCalls().length }} calls fit these filters.
None of the {{ tabCalls().length }} calls fit these filters.
}
</p>
<div class="empty-actions">
Expand All @@ -739,15 +752,21 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
} @else {
<div class="empty-box">
<p class="empty-lead">
@if (allCalls().length) {
@if (tabCalls().length) {
No calls of this kind yet.
} @else if (allCalls().length) {
No calls from this tab yet.
} @else {
No calls yet.
}
</p>
<p class="muted">
Navigate in the app to see page renders, load() fetches, form actions, server
functions and API calls.
@if (!tabCalls().length && allCalls().length) {
Tick “All tabs (including untagged)” to see the
{{ allCalls().length }} calls from other tabs or tied to no tab.
}
</p>
</div>
}
Expand Down Expand Up @@ -2212,7 +2231,18 @@ export class AnalogInspector {
}
return open;
});
/** Every call the dev server keeps, from every tab. */
readonly allCalls = computed(() => this.state().calls ?? []);
/** The tab the Server view shows by default: the panel's page. */
readonly scopePageId = computed(() => this.page()?.pageId);
/** Shows the calls of every tab, and those the dev server tied to no tab. */
readonly allTabs = signal(false);
/** The calls of the panel's tab, or every call with "All tabs" on or no page reporting. */
readonly tabCalls = computed(() => {
const pageId = this.scopePageId();
const calls = this.allCalls();
return this.allTabs() || !pageId ? calls : calls.filter((c) => c.pageId === pageId);
});
readonly maxServerCalls = MAX_SERVER_CALLS;
readonly callQuery = signal('');
readonly failedOnly = signal(false);
Expand All @@ -2224,7 +2254,7 @@ export class AnalogInspector {
const kind = this.kind();
const query = this.callQuery().trim().toLowerCase();
const failedOnly = this.failedOnly();
return this.allCalls().filter(
return this.tabCalls().filter(
(c) =>
(kind === 'all' || c.kind === kind) &&
(!failedOnly || this.isFailed(c)) &&
Expand All @@ -2249,13 +2279,27 @@ export class AnalogInspector {
});
readonly serverFns = computed(() => this.project()?.serverFns ?? []);
private readonly fnById = computed(() => new Map(this.serverFns().map((fn) => [fn.id, fn])));
/** The routes and server functions the shown calls touch, so a warning about another tab stays hidden. */
private readonly shownRoutes = computed(
() => new Set(this.tabCalls().map((c) => `${c.kind} ${c.route ?? ''}`)),
);
readonly refetchedFns = computed(() =>
Array.from(
new Set((this.state().refetches ?? []).map((r) => this.fnById().get(r.id)?.name ?? r.id)),
new Set(
(this.state().refetches ?? [])
.filter((r) => this.shownRoutes().has(`fn ${r.id}`))
.map((r) => this.fnById().get(r.id)?.name ?? r.id),
),
),
);
readonly duplicates = computed(() =>
Array.from(new Set((this.state().duplicates ?? []).map((d) => d.route))),
Array.from(
new Set(
(this.state().duplicates ?? [])
.filter((d) => this.shownRoutes().has(`load ${d.route}`))
.map((d) => d.route),
),
),
);
readonly modeCounts = computed(() =>
(['ssr', 'ssg', 'cached', 'client', 'redirect'] as const)
Expand Down Expand Up @@ -2310,7 +2354,7 @@ export class AnalogInspector {
const { errors, warnings } = this.lintCount();
return [
{ id: 'routes' as View, label: 'Routes', count: this.pageCount(), tone: '' },
{ id: 'server' as View, label: 'Server', count: this.allCalls().length, tone: '' },
{ id: 'server' as View, label: 'Server', count: this.tabCalls().length, tone: '' },
{ id: 'render' as View, label: 'Render', count: this.renderRows().length, tone: '' },
{
id: 'content' as View,
Expand Down Expand Up @@ -2432,8 +2476,8 @@ export class AnalogInspector {

kindCount(kind: Kind): number {
return kind === 'all'
? this.allCalls().length
: this.allCalls().filter((c) => c.kind === kind).length;
? this.tabCalls().length
: this.tabCalls().filter((c) => c.kind === kind).length;
}

modeLabel(mode: RenderRow['mode']): string {
Expand All @@ -2450,7 +2494,7 @@ export class AnalogInspector {
}

fnCalls(id: string): number {
return this.allCalls().filter((c) => c.kind === 'fn' && c.route === id).length;
return this.tabCalls().filter((c) => c.kind === 'fn' && c.route === id).length;
}

outcomeLabel(outcome: ActionOutcome): string {
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -968,10 +968,10 @@ These tools cover *Analog apps. Most read your source. Some also read what the V

### The running page

| Tool | What it answers | Reads | Arguments |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------ |
| `analog-inspect-page` | The open page: its files (layouts first, then pages in named outlets with the outlet name), the `load()` data it received, server rendering and hydration state, and hydration errors. | page | none |
| `analog-list-server-calls` | Recent page renders, `load()` fetches, form actions (with their outcome), server functions by name and API calls, with status, time and size. Flags a `load()` fetched twice and a seeded server function read called again. | Vite plugin | `kind` (`page`, `load`, `action`, `fn` or `api`), `route`, `limit` |
| Tool | What it answers | Reads | Arguments |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `analog-inspect-page` | The open page: its files (layouts first, then pages in named outlets with the outlet name), the `load()` data it received, server rendering and hydration state, and hydration errors. | page | none |
| `analog-list-server-calls` | Recent page renders, `load()` fetches, form actions (with their outcome), server functions by name and API calls, with status, time and size. Flags a `load()` fetched twice and a seeded server function read called again. Each call names its tab when the dev server could tell. | Vite plugin | `kind` (`page`, `load`, `action`, `fn` or `api`), `route`, `limit`, `page` (only that tab's calls; leave it out for every call, untagged ones included) |

### Rendering

Expand Down
31 changes: 23 additions & 8 deletions apps/docs/src/content/inspectors/analog.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,15 @@ Type a URL into **Test a URL** and click **Explain** to see which files render i

Page renders (server rendered or client only), `load()` fetches, form actions, server functions and API calls, newest first. Each row shows the status, the time, who called it, and a redacted response preview.

| Control | What it does |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Kind filter | Show one kind of call. Each choice counts every call of that kind. |
| **Find a URL, path or function** search | Keeps calls whose URL, route path, server function name or file contains the text. `Escape` clears it. |
| **Failed only** | Keeps calls with a status of 400 or more, no status, or a form action that failed. |
| **Clear calls** | Empties the list on the dev server. The tab says **Calls cleared.** or **Could not clear the calls.** |
| Control | What it does |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Kind filter | Show one kind of call. Each choice counts the tab's calls of that kind, before the search and **Failed only**. |
| **Find a URL, path or function** search | Keeps calls whose URL, route path, server function name or file contains the text. `Escape` clears it. |
| **Failed only** | Keeps calls with a status of 400 or more, no status, or a form action that failed. |
| **All tabs (including untagged)** | Shows the calls of every tab, and calls the dev server could not tie to a tab. Off by default. |
| **Clear calls** | Empties the list on the dev server, for every tab. The tab says **Calls cleared.** or **Could not clear the calls.** |

The list shows the calls of the tab the panel belongs to (with no page id, the tab that reported last). **All tabs (including untagged)** appears while a page reports. See [Which tab a call belongs to](#which-tab-a-call-belongs-to).

The filters combine, and the count next to them reads like **12 of 80**. If nothing matches, click **Clear filters** to reset the search, **Failed only** and the kind.

Expand Down Expand Up @@ -114,6 +117,18 @@ Checks grouped by rule, each with a fix. The count on the tab adds up errors and
</ngmd-card>
</ngmd-card-grid>

### Which tab a call belongs to

The dev server ties each call to a tab from the best source it has:

| Source | Calls it covers |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `x-pangular-page` header | Browser `HttpClient` calls, such as `load()` fetches on navigation and server functions. With `providePangularHttp()` or `withPangular()`, a dev build sends the tab's page id on requests to its own origin only. |
| Render id | Page renders, and the `load()` and server function requests a render makes over HTTP. The Vite plugin sends the render an id in `Server-Timing` (`pangular-analog`), and the page reports it. Needs a secure context, such as `localhost`. |
| `Referer` | Other requests, such as form actions sent with `fetch()`, when exactly one open tab shows that path. |

A call none of these covers stays untagged, and shows only with **All tabs (including untagged)** or in `analog-list-server-calls` without `page`. A tab's calls leave with the tab: when it closes or reloads, or 30 seconds after its last report or heartbeat.

### Supported layouts

- **Single app**: `package.json`, `vite.config.ts` and `src/app/pages` in one folder. The build output is read from `dist/analog/public`.
Expand Down Expand Up @@ -190,7 +205,7 @@ If an option comes from a variable, a spread (`...`), a template string or a com
| `pangular:analog-scan-routes` | `filter` | File routes in match order, with page, layout and server files. |
| `pangular:analog-scan-url` | `url` (required) | Which files render a URL, or the closest candidates. |
| `pangular:analog-inspect-page` | `page` | The open page: its files, `load()` data, rendering and hydration state. |
| `pangular:analog-list-server-calls` | `kind`, `route`, `limit` | Recent server calls, with server function names and action outcomes. |
| `pangular:analog-list-server-calls` | `kind`, `route`, `limit`, `page` | Recent server calls, with server function names and action outcomes. |
| `pangular:analog-scan-api-routes` | | Server routes with method, URL and file, plus middleware. |
| `pangular:analog-scan-server-functions` | | Server functions with name, method, file, id and call counts. |
| `pangular:analog-run-api` | `path` (required), `method`, `body`, `confirm` | Sends a real request to the dev server. |
Expand All @@ -213,7 +228,7 @@ Response previews and `load()` data redact secret-looking keys, tokens, `Bearer`

### Call history size

The server keeps the last 200 calls, and the **Server** tab renders the newest 150 that match its filters. The server cuts previews to 1000 characters, and page renders have no preview.
The server keeps the last 200 calls of all tabs, and the **Server** tab renders the newest 150 that match its filters. The server cuts previews to 1000 characters, and page renders have no preview.

## FAQ

Expand Down
Loading
Loading