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
292 changes: 212 additions & 80 deletions app/src/pages/signal-inspector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, input, signal, effect, computed } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { DatePipe, JsonPipe } from '@angular/common';
import type { DevframeRpcClient } from 'devframe/client';

interface SignalNode {
Expand All @@ -16,12 +16,27 @@ interface SignalEdge {
producer: number;
}

interface SignalChange {
epoch: number;
value: unknown;
at: number;
source: 'write' | 'sample' | 'initial';
missed?: number;
}

interface SignalGraph {
nodes: SignalNode[];
edges: SignalEdge[];
componentSelector?: string;
history?: Record<string, SignalChange[]>;
}

const SOURCE_LABELS: Record<SignalChange['source'], string> = {
write: 'set',
sample: 'sampled',
initial: 'initial',
};

interface SourceSignal {
name: string;
kind: string;
Expand Down Expand Up @@ -55,7 +70,7 @@ const KIND_COLORS: Record<string, string> = {

@Component({
selector: 'app-signal-inspector',
imports: [JsonPipe],
imports: [DatePipe, JsonPipe],
template: `
<div class="toolbar">
<input
Expand Down Expand Up @@ -109,81 +124,113 @@ const KIND_COLORS: Record<string, string> = {
}
</div>

<div class="nodes">
<ul class="nodes" role="list">
@for (node of filteredNodes(); track node.id) {
<div
class="node-card"
[class.selected]="selectedNode()?.id === node.id"
(click)="selectNode(node)"
>
<div class="node-header">
<span class="kind-badge" [style.background]="kindColor(node.kind)">{{
node.kind
}}</span>
<span class="node-label">{{ node.label ?? '(unnamed)' }}</span>
@if (node.watched) {
<span class="watched-badge">watching</span>
<li>
<button
type="button"
class="node-card"
[class.selected]="selectedId() === node.id"
[attr.aria-expanded]="selectedId() === node.id"
[attr.aria-controls]="'signal-detail-' + node.id"
(click)="selectNode(node)"
>
<span class="node-header">
<span class="kind-badge" [style.background]="kindColor(node.kind)">{{
node.kind
}}</span>
<span class="node-label">{{ node.label ?? '(unnamed)' }}</span>
@if (node.watched) {
<span class="watched-badge">watching</span>
}
@if (changeCount(node.id); as count) {
<span class="changed-badge"
>{{ count }} {{ count === 1 ? 'change' : 'changes' }}</span
>
}
</span>
@if (node.value !== undefined) {
<span class="node-value">{{ node.value | json }}</span>
}
</div>
@if (node.value !== undefined) {
<div class="node-value">{{ node.value | json }}</div>
<span class="node-meta">
Epoch: {{ node.epoch }}
@if (getDependencies(node).length) {
· Deps: {{ getDependencies(node).length }}
}
@if (getConsumers(node).length) {
· Consumers: {{ getConsumers(node).length }}
}
</span>
</button>
@if (selectedId() === node.id && selectedNode()) {
<div class="detail-panel" [id]="'signal-detail-' + node.id">
<h3>{{ selectedNode()!.label ?? selectedNode()!.id }}</h3>
<dl>
<dt>Kind</dt>
<dd>{{ selectedNode()!.kind }}</dd>
<dt>Epoch</dt>
<dd>{{ selectedNode()!.epoch }}</dd>
@if (selectedNode()!.value !== undefined) {
<dt>Value</dt>
<dd>
<pre>{{ selectedNode()!.value | json }}</pre>
</dd>
}
</dl>
@if (getDependencies(selectedNode()!).length) {
<h4>Dependencies (producers)</h4>
<ul>
@for (dep of getDependencies(selectedNode()!); track dep.id) {
<li>
<span class="kind-badge sm" [style.background]="kindColor(dep.kind)">{{
dep.kind
}}</span>
{{ dep.label ?? dep.id }}
</li>
}
</ul>
}
@if (getConsumers(selectedNode()!).length) {
<h4>Consumers</h4>
<ul>
@for (con of getConsumers(selectedNode()!); track con.id) {
<li>
<span class="kind-badge sm" [style.background]="kindColor(con.kind)">{{
con.kind
}}</span>
{{ con.label ?? con.id }}
</li>
}
</ul>
}
@if (selectedHistory().length) {
<h4 id="value-history-heading">Value history</h4>
<p class="history-summary" aria-live="polite">
{{ changeCount(selectedNode()!.id) }} changes recorded, newest first.
</p>
<ol class="history" aria-labelledby="value-history-heading">
@for (change of selectedHistory(); track change.epoch) {
<li>
<span class="history-meta">
<time>{{ change.at | date: 'HH:mm:ss.SSS' }}</time>
<span class="source-tag" [class]="'source-' + change.source">{{
sourceLabel(change.source)
}}</span>
<span>epoch {{ change.epoch }}</span>
@if (change.missed) {
<span class="missed">{{ change.missed }} earlier not captured</span>
}
</span>
<pre>{{ change.value | json }}</pre>
</li>
}
</ol>
}
</div>
}
<div class="node-meta">
Epoch: {{ node.epoch }}
@if (getDependencies(node).length) {
· Deps: {{ getDependencies(node).length }}
}
@if (getConsumers(node).length) {
· Consumers: {{ getConsumers(node).length }}
}
</div>
</div>
</li>
}
</div>

@if (selectedNode()) {
<aside class="detail-panel">
<h3>{{ selectedNode()!.label ?? selectedNode()!.id }}</h3>
<dl>
<dt>Kind</dt>
<dd>{{ selectedNode()!.kind }}</dd>
<dt>Epoch</dt>
<dd>{{ selectedNode()!.epoch }}</dd>
@if (selectedNode()!.value !== undefined) {
<dt>Value</dt>
<dd>
<pre>{{ selectedNode()!.value | json }}</pre>
</dd>
}
</dl>
@if (getDependencies(selectedNode()!).length) {
<h4>Dependencies (producers)</h4>
<ul>
@for (dep of getDependencies(selectedNode()!); track dep.id) {
<li>
<span class="kind-badge sm" [style.background]="kindColor(dep.kind)">{{
dep.kind
}}</span>
{{ dep.label ?? dep.id }}
</li>
}
</ul>
}
@if (getConsumers(selectedNode()!).length) {
<h4>Consumers</h4>
<ul>
@for (con of getConsumers(selectedNode()!); track con.id) {
<li>
<span class="kind-badge sm" [style.background]="kindColor(con.kind)">{{
con.kind
}}</span>
{{ con.label ?? con.id }}
</li>
}
</ul>
}
</aside>
}
</ul>
}
`,
styles: `
Expand Down Expand Up @@ -251,8 +298,16 @@ const KIND_COLORS: Record<string, string> = {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
.node-card {
display: block;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: #18181b;
border: 1px solid #27272a;
border-radius: 8px;
Expand All @@ -263,6 +318,60 @@ const KIND_COLORS: Record<string, string> = {
.node-card:hover {
border-color: #3f3f46;
}
.node-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.node-value,
.node-meta {
display: block;
}
.changed-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: #422006;
color: #fbbf24;
}
.history-summary {
font-size: 12px;
color: #a1a1aa;
margin: 0 0 6px;
}
.history {
list-style: none;
padding: 0;
margin: 0;
max-height: 320px;
overflow: auto;
}
.history li {
display: block;
padding: 6px 0;
border-top: 1px solid #27272a;
}
.history-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 11px;
color: #a1a1aa;
margin-bottom: 2px;
}
.source-tag {
padding: 0 5px;
border-radius: 3px;
background: #27272a;
color: #e4e4e7;
}
.source-write {
background: #1e3a8a;
color: #dbeafe;
}
.missed {
color: #fbbf24;
}
.node-card.selected {
border-color: var(--accent);
}
Expand Down Expand Up @@ -365,17 +474,26 @@ export class SignalInspector {
graph = signal<SignalGraph | null>(null);
sourceSignals = signal<SourceSignal[]>([]);
filter = signal('');
selectedNode = signal<SignalNode | null>(null);
selectedId = signal<string | null>(null);
selectedNode = computed(
() => this.graph()?.nodes.find((n) => n.id === this.selectedId()) ?? null,
);
selectedHistory = computed(() => {
const id = this.selectedId();
return id ? [...(this.graph()?.history?.[id] ?? [])].reverse() : [];
});

readonly kindLegend = Object.entries(KIND_COLORS).map(([kind, color]) => ({ kind, color }));

filteredNodes = computed(() => {
const g = this.graph();
if (!g) return [];
const q = this.filter().toLowerCase();
return q
const nodes = q
? g.nodes.filter((n) => (n.label ?? '').toLowerCase().includes(q) || n.kind.includes(q))
: g.nodes;
: [...g.nodes];
// Angular orders nodes by last read order, which changes between polls; ids are stable.
return nodes.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
});

filteredSourceSignals = computed(() => {
Expand All @@ -400,10 +518,14 @@ export class SignalInspector {
async loadSignalGraph(client: DevframeRpcClient) {
const my = client.scope('ng-devtools');
const state = await my.rpc.sharedState('signal-graph');
const val = state.value() as any;
if (val?.graph) this.graph.set(val.graph);
// Each open page pushes its own graph; show the one hosting this panel.
const pageId = new URLSearchParams(location.search).get('pageId');
const pick = (val: any) => (pageId && val?.pages?.[pageId]) || val?.graph;
const initial = pick(state.value());
if (initial) this.graph.set(initial);
state.on('updated', (next: any) => {
if (next?.graph) this.graph.set(next.graph);
const graph = pick(next);
if (graph) this.graph.set(graph);
});
}

Expand All @@ -418,7 +540,17 @@ export class SignalInspector {
}

selectNode(node: SignalNode) {
this.selectedNode.set(this.selectedNode()?.id === node.id ? null : node);
this.selectedId.set(this.selectedId() === node.id ? null : node.id);
}

// The first entry is the value seen on connect, not a change.
changeCount(id: string): number {
const list = this.graph()?.history?.[id] ?? [];
return list.reduce((n, c) => n + (c.source === 'initial' ? 0 : 1 + (c.missed ?? 0)), 0);
}

sourceLabel(source: SignalChange['source']) {
return SOURCE_LABELS[source];
}

kindColor(kind: string) {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1,232 changes: 1,232 additions & 0 deletions extension/ui/assets/index-BwNBkkwk.js

Large diffs are not rendered by default.

1,170 changes: 0 additions & 1,170 deletions extension/ui/assets/index-ruy7p20M.js

This file was deleted.

2 changes: 1 addition & 1 deletion extension/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Angular DevTools</title>
<style>*,:before,:after{box-sizing:border-box;margin:0}:root{--accent:#ff6b85}body{color:#e4e4e7;background:#0f0f11;font-family:system-ui,-apple-system,sans-serif}</style>
<script type="module" crossorigin src="./assets/index-ruy7p20M.js"></script>
<script type="module" crossorigin src="./assets/index-BwNBkkwk.js"></script>
</head>
<body>
<app-root></app-root>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"devtools:build-pkg": "pnpm --filter @santoshyadavdev/ng-devtools build",
"devtools:publish": "pnpm --filter @santoshyadavdev/ng-devtools publish --access public",
"extension:build": "pnpm devtools:build && rm -rf extension/ui && cp -r dist/devtools-ui extension/ui",
"extension:zip": "pnpm extension:build && cd extension && zip -r ../dist/ng-devtools-extension.zip . -x '*.DS_Store'"
"extension:zip": "pnpm extension:build && rm -f dist/ng-devtools-extension.zip && cd extension && zip -r ../dist/ng-devtools-extension.zip . -x '*.DS_Store'"
},
"private": true,
"packageManager": "pnpm@10.33.4",
Expand Down
Loading
Loading