@agent-analytics/paperclip-live-analytics-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ function formatRelativeTime(timestamp) {
2
+ if (!timestamp) return 'No updates yet';
3
+ const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
4
+ if (seconds < 5) return 'Updated just now';
5
+ if (seconds < 60) return `Updated ${seconds}s ago`;
6
+ const minutes = Math.round(seconds / 60);
7
+ return `Updated ${minutes}m ago`;
8
+ }
9
+
10
+ function CountryPulse({ liveState }) {
11
+ const hotCountry = liveState.world.hotCountry || 'World';
12
+ return (
13
+ <section className="aa-panel aa-world-panel">
14
+ <div className="aa-panel-header">
15
+ <div>
16
+ <p className="aa-kicker">World / Country View</p>
17
+ <h2>Supporting geography, not dashboard wallpaper.</h2>
18
+ </div>
19
+ <div className="aa-world-hot">{hotCountry}</div>
20
+ </div>
21
+ <div className="aa-world-grid">
22
+ <div className="aa-globe">
23
+ <div className="aa-globe-ring aa-globe-ring-one" />
24
+ <div className="aa-globe-ring aa-globe-ring-two" />
25
+ <div className="aa-globe-ring aa-globe-ring-three" />
26
+ <div className="aa-globe-core">
27
+ <span>Live</span>
28
+ </div>
29
+ </div>
30
+ <div className="aa-country-list">
31
+ {liveState.world.countries.map((country) => (
32
+ <div className="aa-country-row" key={country.country}>
33
+ <div>
34
+ <strong>{country.country}</strong>
35
+ <span>{country.visitors} visitors</span>
36
+ </div>
37
+ <div className="aa-country-bar">
38
+ <div
39
+ className="aa-country-bar-fill"
40
+ style={{ width: `${Math.min(100, (country.events / Math.max(1, liveState.metrics.eventsPerMinute)) * 100)}%` }}
41
+ />
42
+ </div>
43
+ </div>
44
+ ))}
45
+ </div>
46
+ </div>
47
+ </section>
48
+ );
49
+ }
50
+
51
+ function EvidenceColumn({ liveState }) {
52
+ return (
53
+ <section className="aa-panel">
54
+ <div className="aa-panel-header">
55
+ <div>
56
+ <p className="aa-kicker">Operator Evidence</p>
57
+ <h2>Fast feed, top pages, and why the pulse changed.</h2>
58
+ </div>
59
+ </div>
60
+ <div className="aa-evidence-grid">
61
+ <div className="aa-mini-panel">
62
+ <h3>Top Pages</h3>
63
+ {liveState.evidence.topPages.map((page) => (
64
+ <div className="aa-mini-row" key={page.path}>
65
+ <span>{page.path}</span>
66
+ <strong>{page.visitors}</strong>
67
+ </div>
68
+ ))}
69
+ </div>
70
+ <div className="aa-mini-panel">
71
+ <h3>Top Events</h3>
72
+ {liveState.evidence.topEvents.map((event) => (
73
+ <div className="aa-mini-row" key={event.event}>
74
+ <span>{event.event}</span>
75
+ <strong>{event.count}</strong>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ <div className="aa-feed">
81
+ {liveState.evidence.recentEvents.map((event) => (
82
+ <div className="aa-feed-row" key={event.id}>
83
+ <div>
84
+ <strong>{event.event}</strong>
85
+ <span>{event.assetLabel || 'Unmapped asset'} · {event.path || 'no path'} · {event.country || '??'}</span>
86
+ </div>
87
+ <time>{formatRelativeTime(event.timestamp)}</time>
88
+ </div>
89
+ ))}
90
+ </div>
91
+ </section>
92
+ );
93
+ }
94
+
95
+ function AssetCard({ asset, onSnooze, basePath }) {
96
+ return (
97
+ <article className={`aa-asset-card aa-asset-card-${asset.kind}`}>
98
+ <div className="aa-asset-topline">
99
+ <div>
100
+ <p className="aa-kicker">{asset.kind}</p>
101
+ <h3>{asset.label}</h3>
102
+ </div>
103
+ <span className={`aa-status-pill aa-status-${asset.status}`}>{asset.status}</span>
104
+ </div>
105
+
106
+ <div className="aa-asset-metrics">
107
+ <div>
108
+ <span>Visitors</span>
109
+ <strong>{asset.activeVisitors}</strong>
110
+ </div>
111
+ <div>
112
+ <span>Sessions</span>
113
+ <strong>{asset.activeSessions}</strong>
114
+ </div>
115
+ <div>
116
+ <span>Events / min</span>
117
+ <strong>{asset.eventsPerMinute}</strong>
118
+ </div>
119
+ </div>
120
+
121
+ <div className="aa-asset-details">
122
+ <div>
123
+ <span className="aa-label">Project</span>
124
+ <strong>{asset.agentAnalyticsProject}</strong>
125
+ </div>
126
+ <div>
127
+ <span className="aa-label">Hot country</span>
128
+ <strong>{asset.lastHotCountry || 'Waiting for stream'}</strong>
129
+ </div>
130
+ <div>
131
+ <span className="aa-label">Updated</span>
132
+ <strong>{formatRelativeTime(asset.lastUpdatedAt)}</strong>
133
+ </div>
134
+ </div>
135
+
136
+ <div className="aa-asset-evidence">
137
+ <div>
138
+ <span className="aa-label">Top page</span>
139
+ <strong>{asset.topPages[0]?.path || 'No pages yet'}</strong>
140
+ </div>
141
+ <div>
142
+ <span className="aa-label">Top event</span>
143
+ <strong>{asset.topEvents[0]?.event || 'No events yet'}</strong>
144
+ </div>
145
+ </div>
146
+
147
+ <div className="aa-asset-actions">
148
+ {asset.paperclipProjectId ? (
149
+ <a className="aa-button aa-button-secondary" href={`${basePath || ''}/projects/${asset.paperclipProjectId}`}>
150
+ Open mapped project
151
+ </a>
152
+ ) : (
153
+ <span className="aa-muted-note">No Paperclip project linked yet</span>
154
+ )}
155
+ <button className="aa-button aa-button-ghost" onClick={() => onSnooze(asset.assetKey)}>
156
+ Snooze 30m
157
+ </button>
158
+ </div>
159
+ </article>
160
+ );
161
+ }
162
+
163
+ export function PageSurface({ liveState, onSnooze, basePath = '' }) {
164
+ return (
165
+ <div className="aa-page-shell">
166
+ <header className="aa-hero">
167
+ <div>
168
+ <p className="aa-kicker">Agent Analytics Live</p>
169
+ <h1>Ambient pulse for the company, backed by raw evidence.</h1>
170
+ <p className="aa-hero-copy">{liveState.connection.detail}</p>
171
+ </div>
172
+ <div className="aa-hero-status">
173
+ <span className={`aa-status-pill aa-status-${liveState.connection.status}`}>{liveState.connection.label}</span>
174
+ <p>{liveState.account?.email || 'No connected account'}</p>
175
+ </div>
176
+ </header>
177
+
178
+ <section className="aa-metric-grid">
179
+ <div className="aa-metric-card">
180
+ <span>Active visitors</span>
181
+ <strong>{liveState.metrics.activeVisitors}</strong>
182
+ </div>
183
+ <div className="aa-metric-card">
184
+ <span>Active sessions</span>
185
+ <strong>{liveState.metrics.activeSessions}</strong>
186
+ </div>
187
+ <div className="aa-metric-card">
188
+ <span>Events / min</span>
189
+ <strong>{liveState.metrics.eventsPerMinute}</strong>
190
+ </div>
191
+ <div className="aa-metric-card">
192
+ <span>Visible assets</span>
193
+ <strong>{liveState.metrics.assetsVisible}</strong>
194
+ </div>
195
+ </section>
196
+
197
+ <div className="aa-main-grid">
198
+ <CountryPulse liveState={liveState} />
199
+ <EvidenceColumn liveState={liveState} />
200
+ </div>
201
+
202
+ <section className="aa-assets-section">
203
+ <div className="aa-panel-header">
204
+ <div>
205
+ <p className="aa-kicker">Mapped Assets</p>
206
+ <h2>Each card ties live movement back to an owned company asset.</h2>
207
+ </div>
208
+ </div>
209
+ <div className="aa-asset-grid">
210
+ {liveState.assets.map((asset) => (
211
+ <AssetCard key={asset.assetKey} asset={asset} onSnooze={onSnooze} basePath={basePath} />
212
+ ))}
213
+ </div>
214
+ </section>
215
+ </div>
216
+ );
217
+ }
218
+
@@ -0,0 +1,236 @@
1
+ import { useState } from 'react';
2
+
3
+ const EMPTY_MAPPING = {
4
+ assetKey: '',
5
+ label: '',
6
+ kind: 'website',
7
+ paperclipProjectId: '',
8
+ agentAnalyticsProject: '',
9
+ primaryHostname: '',
10
+ enabled: true,
11
+ };
12
+
13
+ function MappingRow({ mapping, onRemove }) {
14
+ return (
15
+ <div className="aa-settings-row">
16
+ <div>
17
+ <strong>{mapping.label}</strong>
18
+ <span>{mapping.kind} · {mapping.agentAnalyticsProject}</span>
19
+ </div>
20
+ <button className="aa-button aa-button-ghost" onClick={() => onRemove(mapping.assetKey)}>
21
+ Remove
22
+ </button>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ export function SettingsSurface({
28
+ settingsData,
29
+ onStartAuth,
30
+ onCompleteAuth,
31
+ onReconnect,
32
+ onDisconnect,
33
+ onSaveSettings,
34
+ onUpsertMapping,
35
+ onRemoveMapping,
36
+ }) {
37
+ const [formState, setFormState] = useState(() => ({
38
+ agentAnalyticsBaseUrl: settingsData.settings.agentAnalyticsBaseUrl,
39
+ liveWindowSeconds: settingsData.settings.liveWindowSeconds,
40
+ pollIntervalSeconds: settingsData.settings.pollIntervalSeconds,
41
+ pluginEnabled: settingsData.settings.pluginEnabled,
42
+ }));
43
+ const [mappingForm, setMappingForm] = useState(EMPTY_MAPPING);
44
+ const [exchangeCode, setExchangeCode] = useState('');
45
+
46
+ return (
47
+ <div className="aa-settings-shell">
48
+ <section className="aa-panel">
49
+ <div className="aa-panel-header">
50
+ <div>
51
+ <p className="aa-kicker">Connection</p>
52
+ <h2>Login-first auth, worker-held tokens.</h2>
53
+ </div>
54
+ <span className={`aa-status-pill aa-status-${settingsData.auth.status}`}>{settingsData.auth.status}</span>
55
+ </div>
56
+
57
+ <div className="aa-settings-grid">
58
+ <div className="aa-settings-stack">
59
+ <div className="aa-settings-row">
60
+ <div>
61
+ <strong>Connected account</strong>
62
+ <span>{settingsData.auth.accountSummary?.email || 'Not connected'}</span>
63
+ </div>
64
+ {settingsData.auth.connected ? (
65
+ <button className="aa-button aa-button-secondary" onClick={onDisconnect}>Disconnect</button>
66
+ ) : (
67
+ <button className="aa-button aa-button-primary" onClick={onStartAuth}>Start login</button>
68
+ )}
69
+ </div>
70
+
71
+ {settingsData.auth.pendingAuthRequest ? (
72
+ <div className="aa-auth-box">
73
+ <label>
74
+ Approval URL
75
+ <a href={settingsData.auth.pendingAuthRequest.authorizeUrl} target="_blank" rel="noreferrer">
76
+ {settingsData.auth.pendingAuthRequest.authorizeUrl}
77
+ </a>
78
+ </label>
79
+ <label>
80
+ Finish code
81
+ <input value={exchangeCode} onChange={(event) => setExchangeCode(event.target.value)} placeholder="Paste finish code" />
82
+ </label>
83
+ <div className="aa-inline-actions">
84
+ <button className="aa-button aa-button-primary" onClick={() => onCompleteAuth(settingsData.auth.pendingAuthRequest.authRequestId, exchangeCode)}>
85
+ Complete login
86
+ </button>
87
+ <button className="aa-button aa-button-ghost" onClick={onReconnect}>Refresh session</button>
88
+ </div>
89
+ </div>
90
+ ) : null}
91
+ </div>
92
+
93
+ <div className="aa-mini-panel">
94
+ <h3>Discovered projects</h3>
95
+ {(settingsData.discoveredProjects || []).map((project) => (
96
+ <div className="aa-mini-row" key={project.id || project.name}>
97
+ <span>{project.name}</span>
98
+ <strong>{project.allowed_origins || '*'}</strong>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ </section>
104
+
105
+ <section className="aa-panel">
106
+ <div className="aa-panel-header">
107
+ <div>
108
+ <p className="aa-kicker">Rollout Controls</p>
109
+ <h2>Keep the live window short and the poll cadence explicit.</h2>
110
+ </div>
111
+ </div>
112
+
113
+ <div className="aa-form-grid">
114
+ <label>
115
+ Agent Analytics base URL
116
+ <input
117
+ value={formState.agentAnalyticsBaseUrl}
118
+ onChange={(event) => setFormState((current) => ({ ...current, agentAnalyticsBaseUrl: event.target.value }))}
119
+ />
120
+ </label>
121
+ <label>
122
+ Live window seconds
123
+ <input
124
+ type="number"
125
+ value={formState.liveWindowSeconds}
126
+ onChange={(event) => setFormState((current) => ({ ...current, liveWindowSeconds: Number(event.target.value) }))}
127
+ />
128
+ </label>
129
+ <label>
130
+ Poll interval seconds
131
+ <input
132
+ type="number"
133
+ value={formState.pollIntervalSeconds}
134
+ onChange={(event) => setFormState((current) => ({ ...current, pollIntervalSeconds: Number(event.target.value) }))}
135
+ />
136
+ </label>
137
+ <label className="aa-checkbox">
138
+ <input
139
+ type="checkbox"
140
+ checked={formState.pluginEnabled}
141
+ onChange={(event) => setFormState((current) => ({ ...current, pluginEnabled: event.target.checked }))}
142
+ />
143
+ Plugin enabled
144
+ </label>
145
+ </div>
146
+
147
+ <div className="aa-inline-actions">
148
+ <button className="aa-button aa-button-primary" onClick={() => onSaveSettings(formState)}>
149
+ Save controls
150
+ </button>
151
+ <button className="aa-button aa-button-ghost" onClick={onReconnect}>
152
+ Revalidate connection
153
+ </button>
154
+ </div>
155
+ </section>
156
+
157
+ <section className="aa-panel">
158
+ <div className="aa-panel-header">
159
+ <div>
160
+ <p className="aa-kicker">Asset Mapping</p>
161
+ <h2>Explicit Paperclip asset to Agent Analytics project links.</h2>
162
+ </div>
163
+ </div>
164
+
165
+ <div className="aa-form-grid">
166
+ <label>
167
+ Asset key
168
+ <input value={mappingForm.assetKey} onChange={(event) => setMappingForm((current) => ({ ...current, assetKey: event.target.value }))} />
169
+ </label>
170
+ <label>
171
+ Label
172
+ <input value={mappingForm.label} onChange={(event) => setMappingForm((current) => ({ ...current, label: event.target.value }))} />
173
+ </label>
174
+ <label>
175
+ Kind
176
+ <select value={mappingForm.kind} onChange={(event) => setMappingForm((current) => ({ ...current, kind: event.target.value }))}>
177
+ <option value="website">website</option>
178
+ <option value="docs">docs</option>
179
+ <option value="app">app</option>
180
+ <option value="api">api</option>
181
+ <option value="other">other</option>
182
+ </select>
183
+ </label>
184
+ <label>
185
+ Paperclip project ID
186
+ <input value={mappingForm.paperclipProjectId} onChange={(event) => setMappingForm((current) => ({ ...current, paperclipProjectId: event.target.value }))} />
187
+ </label>
188
+ <label>
189
+ Agent Analytics project
190
+ <input value={mappingForm.agentAnalyticsProject} onChange={(event) => setMappingForm((current) => ({ ...current, agentAnalyticsProject: event.target.value }))} />
191
+ </label>
192
+ <label>
193
+ Primary hostname
194
+ <input value={mappingForm.primaryHostname} onChange={(event) => setMappingForm((current) => ({ ...current, primaryHostname: event.target.value }))} />
195
+ </label>
196
+ <label className="aa-checkbox">
197
+ <input
198
+ type="checkbox"
199
+ checked={mappingForm.enabled}
200
+ onChange={(event) => setMappingForm((current) => ({ ...current, enabled: event.target.checked }))}
201
+ />
202
+ Enabled
203
+ </label>
204
+ </div>
205
+
206
+ <div className="aa-inline-actions">
207
+ <button
208
+ className="aa-button aa-button-primary"
209
+ onClick={() => {
210
+ onUpsertMapping(mappingForm);
211
+ setMappingForm(EMPTY_MAPPING);
212
+ }}
213
+ >
214
+ Save mapping
215
+ </button>
216
+ </div>
217
+
218
+ <div className="aa-settings-stack">
219
+ {settingsData.settings.monitoredAssets.map((mapping) => (
220
+ <MappingRow key={mapping.assetKey} mapping={mapping} onRemove={onRemoveMapping} />
221
+ ))}
222
+ </div>
223
+ </section>
224
+
225
+ {settingsData.validation.warnings.length > 0 ? (
226
+ <section className="aa-panel aa-panel-warning">
227
+ <h3>Warnings</h3>
228
+ {settingsData.validation.warnings.map((warning) => (
229
+ <p key={warning}>{warning}</p>
230
+ ))}
231
+ </section>
232
+ ) : null}
233
+ </div>
234
+ );
235
+ }
236
+
@@ -0,0 +1,37 @@
1
+ export function WidgetSurface({ widget, fullPageHref = '?surface=page' }) {
2
+ return (
3
+ <section className="aa-widget">
4
+ <div className="aa-widget-header">
5
+ <div>
6
+ <p className="aa-kicker">Live Status</p>
7
+ <h2>{widget.connection.label}</h2>
8
+ </div>
9
+ <span className={`aa-status-pill aa-status-${widget.connection.status}`}>{widget.tier || 'tier unknown'}</span>
10
+ </div>
11
+
12
+ <div className="aa-widget-metrics">
13
+ <div>
14
+ <span>Visitors</span>
15
+ <strong>{widget.metrics.activeVisitors}</strong>
16
+ </div>
17
+ <div>
18
+ <span>Sessions</span>
19
+ <strong>{widget.metrics.activeSessions}</strong>
20
+ </div>
21
+ <div>
22
+ <span>EPM</span>
23
+ <strong>{widget.metrics.eventsPerMinute}</strong>
24
+ </div>
25
+ </div>
26
+
27
+ <div className="aa-widget-footer">
28
+ <div>
29
+ <span className="aa-label">Top asset</span>
30
+ <strong>{widget.topAsset?.label || 'No live assets yet'}</strong>
31
+ </div>
32
+ <a className="aa-button aa-button-secondary" href={fullPageHref}>Open full live page</a>
33
+ </div>
34
+ </section>
35
+ );
36
+ }
37
+
@@ -0,0 +1,36 @@
1
+ import { ACTION_KEYS, DATA_KEYS, PLUGIN_DISPLAY_NAME, PLUGIN_ID } from '../shared/constants.js';
2
+ import { PaperclipLiveAnalyticsService } from './service.js';
3
+
4
+ export const manifest = {
5
+ id: PLUGIN_ID,
6
+ displayName: PLUGIN_DISPLAY_NAME,
7
+ entrypoints: {
8
+ worker: './src/worker/index.js',
9
+ ui: './dist',
10
+ },
11
+ data: DATA_KEYS,
12
+ actions: ACTION_KEYS,
13
+ surfaces: ['page', 'dashboardWidget', 'settingsPage'],
14
+ };
15
+
16
+ export async function setup(ctx) {
17
+ const service = new PaperclipLiveAnalyticsService(ctx);
18
+ await service.register();
19
+
20
+ return {
21
+ async shutdown() {
22
+ await service.shutdown();
23
+ },
24
+ async health() {
25
+ return {
26
+ ok: true,
27
+ plugin: PLUGIN_ID,
28
+ };
29
+ },
30
+ async onConfigChange({ companyId }) {
31
+ await service.ensureLiveState(companyId, { forceSync: true });
32
+ },
33
+ };
34
+ }
35
+
36
+ export default manifest;
@@ -0,0 +1,37 @@
1
+ import { LIVE_STREAM_CHANNEL } from '../shared/constants.js';
2
+
3
+ export async function registerDataHandler(ctx, key, handler) {
4
+ if (ctx?.data?.register) {
5
+ return ctx.data.register(key, handler);
6
+ }
7
+ if (ctx?.registerData) {
8
+ return ctx.registerData(key, handler);
9
+ }
10
+ throw new Error(`Paperclip data registration is unavailable for key "${key}"`);
11
+ }
12
+
13
+ export async function registerActionHandler(ctx, key, handler) {
14
+ if (ctx?.actions?.register) {
15
+ return ctx.actions.register(key, handler);
16
+ }
17
+ if (ctx?.registerAction) {
18
+ return ctx.registerAction(key, handler);
19
+ }
20
+ throw new Error(`Paperclip action registration is unavailable for key "${key}"`);
21
+ }
22
+
23
+ export async function openLiveChannel(ctx, companyId) {
24
+ if (!ctx?.streams?.open) return;
25
+ await ctx.streams.open(LIVE_STREAM_CHANNEL, companyId);
26
+ }
27
+
28
+ export async function emitLiveState(ctx, companyId, payload) {
29
+ if (!ctx?.streams?.emit) return;
30
+ await ctx.streams.emit(LIVE_STREAM_CHANNEL, payload);
31
+ }
32
+
33
+ export async function closeLiveChannel(ctx, companyId) {
34
+ if (!ctx?.streams?.close) return;
35
+ await ctx.streams.close(LIVE_STREAM_CHANNEL, companyId);
36
+ }
37
+