@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
@@ -1,1436 +1,1436 @@
1
- import {
2
- FunctionJobError,
3
- HawkApiError,
4
- type AiInfo,
5
- type AiModel,
6
- type AiResult,
7
- type AppSchedule,
8
- type FunctionJob,
9
- type HawkApp,
10
- type HawkAppContext,
11
- type IndexItemsResult,
12
- type IndexRun,
13
- type SearchRequest,
14
- type SearchResponse,
15
- type StorageFileEntry,
16
- type StorageListEntry,
17
- type StorageUsage,
18
- type SystemParameter,
19
- } from '@bridgeline-digital/hawk-app-bridge/client';
20
- import { useRef, useState, type ReactNode } from 'react';
21
- import { useHawkApp } from './useHawkApp';
22
-
23
- /**
24
- * Internal test app for the Hawk Apps platform. One tab per feature; every call shows its outcome, timing and error
25
- * code, so a failing platform change is easy to spot. Not a product: keep it small and keep it working.
26
- */
27
- const TABS = ['Context', 'Analytics', 'Dashboard', 'Search', 'Recommendations', 'Index', 'Storage', 'Config', 'Queries', 'Functions', 'Jobs', 'System parameters', 'AI', 'Host'] as const;
28
- type Tab = (typeof TABS)[number];
29
-
30
- export function App() {
31
- const hawk = useHawkApp();
32
- const [tab, setTab] = useState<Tab>('Context');
33
-
34
- if (hawk.status === 'connecting') return <p className="muted">Connecting…</p>;
35
- if (hawk.status === 'error') return <p className="error">{hawk.message}</p>;
36
-
37
- return (
38
- <main>
39
- <h2>Hawk Apps Showcase</h2>
40
- <nav className="tabs">
41
- {TABS.map((t) => (
42
- <button key={t} className={t === tab ? 'active' : ''} onClick={() => setTab(t)}>
43
- {t}
44
- </button>
45
- ))}
46
- </nav>
47
- {tab === 'Context' && <ContextTab context={hawk.context} />}
48
- {tab === 'Analytics' && <AnalyticsTab app={hawk.app} context={hawk.context} />}
49
- {tab === 'Dashboard' && <DashboardTab app={hawk.app} />}
50
- {tab === 'Search' && <SearchTab app={hawk.app} context={hawk.context} />}
51
- {tab === 'Recommendations' && <RecommendationsTab app={hawk.app} />}
52
- {tab === 'Index' && <IndexTab app={hawk.app} />}
53
- {tab === 'Storage' && <StorageTab app={hawk.app} />}
54
- {tab === 'Config' && <ConfigTab app={hawk.app} />}
55
- {tab === 'Queries' && <QueriesTab app={hawk.app} context={hawk.context} />}
56
- {tab === 'Functions' && <FunctionsTab app={hawk.app} context={hawk.context} />}
57
- {tab === 'Jobs' && <JobsTab app={hawk.app} />}
58
- {tab === 'System parameters' && <SystemParametersTab app={hawk.app} context={hawk.context} />}
59
- {tab === 'AI' && <AiTab app={hawk.app} />}
60
- {tab === 'Host' && <HostTab app={hawk.app} />}
61
- </main>
62
- );
63
- }
64
-
65
- // ---------- shared ----------
66
-
67
- interface Outcome {
68
- label: string;
69
- ok: boolean;
70
- ms: number;
71
- summary: string;
72
- detail?: unknown;
73
- }
74
-
75
- /** Runs a call and records how it went: never throws, so one failure doesn't hide the others. */
76
- async function measure(label: string, call: () => Promise<{ summary: string; detail?: unknown }>): Promise<Outcome> {
77
- const started = performance.now();
78
- try {
79
- const { summary, detail } = await call();
80
- return { label, ok: true, ms: Math.round(performance.now() - started), summary, detail };
81
- } catch (e) {
82
- return { label, ok: false, ms: Math.round(performance.now() - started), summary: describe(e) };
83
- }
84
- }
85
-
86
- function describe(e: unknown): string {
87
- if (e instanceof HawkApiError) return `${e.status}${e.code ? ` ${e.code}` : ''}: ${e.message}`;
88
- return e instanceof Error ? e.message : String(e);
89
- }
90
-
91
- function Results({ outcomes }: { outcomes: Outcome[] }) {
92
- if (!outcomes.length) return null;
93
- return (
94
- <div className="results">
95
- {outcomes.map((o, i) => {
96
- const line = (
97
- <>
98
- <span className="badge">{o.ok ? 'OK' : 'FAIL'}</span> {o.label} · {o.ms} ms · {o.summary}
99
- </>
100
- );
101
- // Only results with something to show expand; the rest are a plain line (no empty arrow).
102
- return o.detail === undefined || o.detail === null ? (
103
- <div key={i} className={`result ${o.ok ? 'ok' : 'fail'}`}>
104
- {line}
105
- </div>
106
- ) : (
107
- <details key={i} className={o.ok ? 'ok' : 'fail'}>
108
- <summary>{line}</summary>
109
- <pre>{typeof o.detail === 'string' ? o.detail : JSON.stringify(o.detail, null, 2)}</pre>
110
- </details>
111
- );
112
- })}
113
- </div>
114
- );
115
- }
116
-
117
- function Card({ title, children }: { title: string; children: ReactNode }) {
118
- return (
119
- <section className="card">
120
- <h3>{title}</h3>
121
- {children}
122
- </section>
123
- );
124
- }
125
-
126
- /** The host's date range as YYYY-MM-DD (the last 30 days when the host sends none). */
127
- function range(context: HawkAppContext): { from: string; to: string } {
128
- if (context.dateRange) return { from: context.dateRange.from.slice(0, 10), to: context.dateRange.to.slice(0, 10) };
129
- const to = new Date();
130
- const from = new Date(to.getTime() - 29 * 86_400_000);
131
- return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
132
- }
133
-
134
- /** The rows shown under a result: all of them up to 100, so a result can be checked in full. */
135
- const PREVIEW_ROWS = 100;
136
- const preview = <T,>(rows: T[]) => rows.slice(0, PREVIEW_ROWS);
137
- const shown = (rows: unknown[]) => (rows.length > PREVIEW_ROWS ? ` (showing ${PREVIEW_ROWS})` : '');
138
-
139
- // ---------- tabs ----------
140
-
141
- function ContextTab({ context }: { context: HawkAppContext }) {
142
- return (
143
- <Card title="Context from the host">
144
- <p className="muted">Everything the host sent in the handshake (and later context changes).</p>
145
- <pre>{JSON.stringify(context, null, 2)}</pre>
146
- </Card>
147
- );
148
- }
149
-
150
- function AnalyticsTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
151
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
152
- const [busy, setBusy] = useState(false);
153
- const reports = ['searches', 'queries', 'assistant-prompts'];
154
-
155
- const run = async () => {
156
- setBusy(true);
157
- const results = await Promise.all(
158
- reports.map((name) =>
159
- measure(`analytics.report('${name}')`, async () => {
160
- const report = await app.analytics.report(name, { limit: 10 });
161
- return { summary: `${report.data.length} rows (total ${report.totalRecords ?? '?'})${shown(report.data)}`, detail: preview(report.data) };
162
- }),
163
- ),
164
- );
165
- setOutcomes(results);
166
- setBusy(false);
167
- };
168
-
169
- return (
170
- <Card title="Analytics API (reports:read)">
171
- <p className="muted">
172
- Dates default to the host range ({range(context).from} – {range(context).to}). Reports: {reports.join(', ')}.
173
- </p>
174
- <button disabled={busy} onClick={run}>
175
- Run reports
176
- </button>
177
- <Results outcomes={outcomes} />
178
- </Card>
179
- );
180
- }
181
-
182
- function DashboardTab({ app }: { app: HawkApp }) {
183
- const [path, setPath] = useState('Synonym');
184
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
185
-
186
- const run = async () =>
187
- setOutcomes([
188
- await measure(`dashboard.get('${path}')`, async () => {
189
- const data = await app.dashboard.get<unknown>(path);
190
- const rows = Array.isArray(data) ? data : null;
191
- return { summary: rows ? `${rows.length} items${shown(rows)}` : 'object', detail: rows ? preview(rows) : data };
192
- }),
193
- // Versioned paths must be rejected: the platform picks the API version.
194
- await measure(`dashboard.get('v11/${path}') (expect 400)`, async () => {
195
- await app.dashboard.get(`v11/${path}`);
196
- return { summary: 'unexpectedly allowed' };
197
- }),
198
- ]);
199
-
200
- return (
201
- <>
202
- <Card title="HawkSearch dashboard API (hawksearch:read)">
203
- <label>
204
- Path <input value={path} onChange={(e) => setPath(e.target.value)} />
205
- </label>{' '}
206
- <button onClick={run}>GET</button>
207
- <Results outcomes={outcomes} />
208
- </Card>
209
- <DashboardWriteTest app={app} />
210
- </>
211
- );
212
- }
213
-
214
- /** HawkSearch's Synonym model (HawkSearch.Contracts.Api.Synonym). */
215
- interface Synonym {
216
- Id: number;
217
- SearchTerm: string;
218
- SynonymGroup: string;
219
- MultiWay: boolean;
220
- Comments?: string | null;
221
- }
222
-
223
- /** Expects the call to fail with `status` (and `code`, when given); anything else, including success, is a failure. */
224
- async function expectError(label: string, status: number, code: string | undefined, call: () => Promise<unknown>): Promise<Outcome> {
225
- return measure(`${label} (expect ${status}${code ? ` ${code}` : ''})`, async () => {
226
- try {
227
- await call();
228
- } catch (e) {
229
- if (e instanceof HawkApiError && e.status === status && (!code || e.code === code)) return { summary: `${describe(e)} (as expected)` };
230
- throw e;
231
- }
232
- throw new Error('unexpectedly allowed');
233
- });
234
- }
235
-
236
- /**
237
- * hawksearch:write round trip on a throwaway synonym: create, read back, delete, check it's gone. The synonym is
238
- * clearly marked as a test and deleted in the same run (also when the read-back fails).
239
- */
240
- function DashboardWriteTest({ app }: { app: HawkApp }) {
241
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
242
- const [busy, setBusy] = useState(false);
243
-
244
- const go = async (runs: () => Promise<Outcome[]>) => {
245
- setBusy(true);
246
- setOutcomes(await runs());
247
- setBusy(false);
248
- };
249
-
250
- const roundTrip = async (): Promise<Outcome[]> => {
251
- const marker = `hawk-apps-showcase-${Date.now()}`;
252
- const body = {
253
- SearchTerm: marker,
254
- SynonymGroup: `${marker}-a,${marker}-b`,
255
- MultiWay: false,
256
- Comments: 'Created by the Hawk Apps showcase app (write test); deleted right away. Safe to delete.',
257
- };
258
- const results: Outcome[] = [];
259
- let id: number | undefined;
260
-
261
- results.push(
262
- await measure("dashboard.post('Synonym')", async () => {
263
- const created = await app.dashboard.post<Synonym>('Synonym', body);
264
- id = created?.Id;
265
- if (!id) throw new Error(`no Id in the response: ${JSON.stringify(created)}`);
266
- return { summary: `created synonym ${id} (${marker})`, detail: created };
267
- }),
268
- );
269
- if (!id) return results;
270
-
271
- results.push(
272
- await measure(`dashboard.get('Synonym/${id}')`, async () => {
273
- const synonym = await app.dashboard.get<Synonym>(`Synonym/${id}`);
274
- if (synonym?.SearchTerm !== marker) throw new Error(`read back ${JSON.stringify(synonym?.SearchTerm)}, expected ${marker}`);
275
- return { summary: `read back: ${synonym.SearchTerm} → ${synonym.SynonymGroup}`, detail: synonym };
276
- }),
277
- );
278
- results.push(
279
- await measure(`dashboard.delete('Synonym/${id}')`, async () => {
280
- const removed = await app.dashboard.delete<Synonym | undefined>(`Synonym/${id}`);
281
- return { summary: 'deleted', detail: removed };
282
- }),
283
- );
284
- // HawkSearch answers 404 for a deleted synonym (its own error body, no platform code).
285
- results.push(await expectError(`dashboard.get('Synonym/${id}') after delete`, 404, undefined, () => app.dashboard.get(`Synonym/${id}`)));
286
- return results;
287
- };
288
-
289
- // Field isn't a writable resource. The body is empty, so even a wrongly allowed call couldn't create a field.
290
- const notAllowed = () => expectError("dashboard.post('Field')", 403, 'path_not_allowed', () => app.dashboard.post('Field', {}));
291
-
292
- return (
293
- <Card title="Write test (hawksearch:write)">
294
- <p className="muted">
295
- Creates a synonym marked <code>hawk-apps-showcase-…</code> in the current client, reads it back and deletes it. Writes are
296
- audit-logged and never retried; 120 per minute per app and client.
297
- </p>
298
- <div className="buttons">
299
- <button disabled={busy} onClick={() => go(roundTrip)}>
300
- Create, read, delete synonym
301
- </button>
302
- <button disabled={busy} onClick={() => go(async () => [await notAllowed()])}>
303
- Error case
304
- </button>
305
- </div>
306
- <Results outcomes={outcomes} />
307
- </Card>
308
- );
309
- }
310
-
311
- /** The part of a search result the tab shows. */
312
- function searchSummary(result: SearchResponse): { summary: string; detail: unknown } {
313
- const results = result.Results ?? [];
314
- const total = result.Pagination?.NofResults ?? results.length;
315
- const redirect = result.Redirect?.Location ? ` · redirect ${result.Redirect.Location}` : '';
316
- return { summary: `${total} results${redirect}`, detail: { total, firstDocIds: results.slice(0, 10).map((r) => r.DocId), response: result } };
317
- }
318
-
319
- /** Runs `work` over `items`, at most `limit` at a time (the platform allows 600 searches per minute per app and client). */
320
- async function pool<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
321
- const results: R[] = new Array(items.length);
322
- let next = 0;
323
- await Promise.all(
324
- Array.from({ length: Math.min(limit, items.length) }, async () => {
325
- while (next < items.length) {
326
- const i = next++;
327
- results[i] = await work(items[i]);
328
- }
329
- }),
330
- );
331
- return results;
332
- }
333
-
334
- function SearchTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
335
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
336
- const [busy, setBusy] = useState(false);
337
- const [keyword, setKeyword] = useState('boots');
338
- const [previewDate, setPreviewDate] = useState('');
339
- const [keywords, setKeywords] = useState('boots\njacket\nzzz-no-such-product');
340
-
341
- const go = async (runs: () => Promise<Outcome[]>) => {
342
- setBusy(true);
343
- setOutcomes(await runs());
344
- setBusy(false);
345
- };
346
-
347
- const search = (label: string, request: SearchRequest) =>
348
- measure(label, async () => searchSummary(await app.hawksearch.search(request)));
349
-
350
- const previewRequest = (): SearchRequest => ({
351
- Keyword: keyword,
352
- IsInPreview: true,
353
- ...(previewDate ? { SmartBar: { PreviewDate: `${previewDate}T00:00:00` } } : {}),
354
- });
355
-
356
- const autocomplete = () =>
357
- measure(`hawksearch.autocomplete({ Keyword: ${JSON.stringify(keyword)} })`, async () => {
358
- const result = await app.hawksearch.autocomplete<Record<string, unknown>>({ Keyword: keyword, ProductCount: 5 });
359
- const counts = Object.entries(result)
360
- .filter(([, v]) => Array.isArray(v))
361
- .map(([k, v]) => `${k} ${(v as unknown[]).length}`);
362
- return { summary: counts.join(' · ') || 'no lists', detail: result };
363
- });
364
-
365
- // Every keyword, 5 at a time; zero-result keywords fail so they stand out.
366
- const zeroResults = async (): Promise<Outcome[]> => {
367
- const list = [...new Set(keywords.split('\n').map((k) => k.trim()).filter(Boolean))].slice(0, 100);
368
- return pool(list, 5, (k) =>
369
- measure(`"${k}"`, async () => {
370
- const { summary, detail } = searchSummary(await app.hawksearch.search({ Keyword: k, MaxPerPage: 1 }));
371
- if (summary.startsWith('0 results')) throw new Error('0 results');
372
- return { summary, detail };
373
- }),
374
- );
375
- };
376
-
377
- // The analytics "queries" report (reports:read): the keyword column is the first one named like keyword/query/term.
378
- const loadTopQueries = async (): Promise<Outcome[]> => [
379
- await measure("analytics.report('queries') → keywords", async () => {
380
- const report = await app.analytics.report<Record<string, unknown>>('queries', { limit: 100 });
381
- const column = Object.keys(report.data[0] ?? {}).find((c) => /keyword|query|term/i.test(c));
382
- if (!column) throw new Error(`no keyword column in ${JSON.stringify(Object.keys(report.data[0] ?? {}))}`);
383
- const found = report.data.map((row) => String(row[column] ?? '').trim()).filter(Boolean);
384
- setKeywords(found.join('\n'));
385
- return { summary: `${found.length} keywords from column "${column}"` };
386
- }),
387
- ];
388
-
389
- return (
390
- <>
391
- <Card title="Search (hawksearch:search)">
392
- <p className="muted">
393
- HawkSearch v2 search and autocomplete for engine{' '}
394
- {context.engine ? `${context.engine.id}${context.engine.name ? ` (${context.engine.name})` : ''}` : '(none: expect 400 engine_required)'}. The platform
395
- sets the ClientGuid; preview uses the engine's preview search API.
396
- </p>
397
- <div className="buttons">
398
- <label>
399
- Keyword <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
400
- </label>
401
- <label>
402
- Preview date <input type="date" value={previewDate} onChange={(e) => setPreviewDate(e.target.value)} />
403
- </label>
404
- </div>
405
- <div className="buttons">
406
- <button disabled={busy} onClick={() => go(async () => [await search(`hawksearch.search({ Keyword: ${JSON.stringify(keyword)} })`, { Keyword: keyword })])}>
407
- Search
408
- </button>
409
- <button
410
- disabled={busy}
411
- onClick={() =>
412
- go(() => Promise.all([search('live', { Keyword: keyword }), search(`preview${previewDate ? ` as of ${previewDate}` : ''}`, previewRequest())]))
413
- }
414
- >
415
- Live vs preview
416
- </button>
417
- <button disabled={busy} onClick={() => go(async () => [await autocomplete()])}>
418
- Autocomplete
419
- </button>
420
- <button
421
- disabled={busy}
422
- onClick={() =>
423
- go(async () => [
424
- await search('ClientGuid in the request is replaced (expect normal results)', { Keyword: keyword, ClientGuid: 'not-this-engine' }),
425
- await measure('hawksearch.search(null) (expect TypeError, no call)', async () => {
426
- try {
427
- await app.hawksearch.search(null as never);
428
- } catch (e) {
429
- if (e instanceof TypeError) return { summary: `${e.message} (as expected)` };
430
- throw e;
431
- }
432
- throw new Error('unexpectedly allowed');
433
- }),
434
- ])
435
- }
436
- >
437
- Error cases
438
- </button>
439
- </div>
440
- <Results outcomes={outcomes} />
441
- </Card>
442
- <Card title="Zero-result check">
443
- <p className="muted">One keyword per line (up to 100), 5 searches at a time. Keywords with 0 results show as FAIL.</p>
444
- <textarea rows={6} value={keywords} onChange={(e) => setKeywords(e.target.value)} />
445
- <div className="buttons">
446
- <button disabled={busy} onClick={() => go(loadTopQueries)}>
447
- Load top queries (analytics)
448
- </button>
449
- <button disabled={busy} onClick={() => go(zeroResults)}>
450
- Check keywords
451
- </button>
452
- </div>
453
- </Card>
454
- </>
455
- );
456
- }
457
-
458
- function QueriesTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
459
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
460
- const [busy, setBusy] = useState(false);
461
- const { from, to } = range(context);
462
-
463
- const query = (label: string, name: string, params: Record<string, unknown>) =>
464
- measure(label, async () => {
465
- const result = await app.data.query(name, params as never);
466
- return {
467
- summary: `${result.rowCount} rows · ${result.bytesScanned} B scanned${result.cached ? ' · cached' : ''}${shown(result.rows)}`,
468
- detail: preview(result.rows),
469
- };
470
- });
471
-
472
- const go = async (runs: () => Promise<Outcome[]>) => {
473
- setBusy(true);
474
- setOutcomes(await runs());
475
- setBusy(false);
476
- };
477
-
478
- return (
479
- <Card title="Queries (data:read)">
480
- <p className="muted">
481
- Range {from} – {to}. Queries ship in <code>queries/</code> with each version.
482
- </p>
483
- <div className="buttons">
484
- <button disabled={busy} onClick={() => go(async () => [await query('searches-by-day (allowed table)', 'searches-by-day', { from, to })])}>
485
- Allowed table
486
- </button>
487
- <button disabled={busy} onClick={() => go(async () => [await query('searches-by-day again (expect cached)', 'searches-by-day', { from, to })])}>
488
- Repeat (cached)
489
- </button>
490
- <button
491
- disabled={busy}
492
- onClick={() => go(async () => [await query('assistant-prompts-by-day (sensitive: needs the app opt-in, else TABLE_NOT_FOUND)', 'assistant-prompts-by-day', { from, to })])}
493
- >
494
- Sensitive table
495
- </button>
496
- <button
497
- disabled={busy}
498
- onClick={() =>
499
- go(() =>
500
- // Different ranges so each is a new execution: more than 2 at once per app and client
501
- // hits the limit (429), which the bridge retries until a slot frees up.
502
- Promise.all(
503
- [1, 2, 3, 4, 5].map((n) => {
504
- const end = new Date(new Date(`${to}T00:00:00Z`).getTime() - n * 86_400_000).toISOString().slice(0, 10);
505
- return query(`burst #${n} (to ${end})`, 'searches-by-day', { from, to: end });
506
- }),
507
- ),
508
- )
509
- }
510
- >
511
- Burst ×5 (concurrency)
512
- </button>
513
- <button
514
- disabled={busy}
515
- onClick={() =>
516
- go(async () => [
517
- await query('invalid params (expect 400 invalid_params)', 'searches-by-day', { from: 'not-a-date', to }),
518
- await query('unknown query (expect 404 query_not_found)', 'does-not-exist', {}),
519
- ])
520
- }
521
- >
522
- Error cases
523
- </button>
524
- </div>
525
- <Results outcomes={outcomes} />
526
- </Card>
527
- );
528
- }
529
-
530
- interface ShowcaseStep {
531
- name: string;
532
- ok: boolean;
533
- ms: number;
534
- summary: string;
535
- detail?: unknown;
536
- }
537
-
538
- function FunctionsTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
539
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
540
- const [busy, setBusy] = useState(false);
541
- const [text, setText] = useState('hello');
542
-
543
- const call = (label: string, name: string, input: unknown) =>
544
- measure(label, async () => {
545
- const result = await app.functions.call<Record<string, unknown>>(name, input);
546
- const facts = ['client', 'user', 'version', 'hasSecret', 'searches'].map((key) => `${key} ${JSON.stringify(result?.[key])}`);
547
- return { summary: facts.join(' · '), detail: result };
548
- });
549
-
550
- // The showcase reports its own steps: show each as a result row, after a row for the call itself.
551
- const showcase = async (): Promise<Outcome[]> => {
552
- const started = performance.now();
553
- try {
554
- const result = await app.functions.call<{ steps: ShowcaseStep[]; totalMs: number; runtime: Record<string, unknown> }>(
555
- 'showcase',
556
- range(context),
557
- { timeoutMs: 40_000 },
558
- );
559
- const passed = result.steps.filter((s) => s.ok).length;
560
- return [
561
- {
562
- label: 'showcase (round trip)',
563
- ok: passed === result.steps.length,
564
- ms: Math.round(performance.now() - started),
565
- summary: `${passed}/${result.steps.length} steps ok · ${result.totalMs} ms in the function`,
566
- detail: result.runtime,
567
- },
568
- ...result.steps.map((s) => ({ label: s.name, ok: s.ok, ms: s.ms, summary: s.summary, detail: s.detail })),
569
- ];
570
- } catch (e) {
571
- return [{ label: 'showcase', ok: false, ms: Math.round(performance.now() - started), summary: describe(e) }];
572
- }
573
- };
574
-
575
- const go = async (runs: () => Promise<Outcome[]>) => {
576
- setBusy(true);
577
- setOutcomes(await runs());
578
- setBusy(false);
579
- };
580
-
581
- return (
582
- <Card title="Functions (functions:invoke)">
583
- <p className="muted">
584
- <code>functions/showcase.ts</code> runs every function capability (input validation with an npm package, context, secrets, the
585
- analytics and dashboard APIs, an app query, Athena directly with client-scoped credentials, outbound HTTP, and its own logic)
586
- and reports each step; <code>functions/echo.ts</code> is the minimal one. Both ship with each version. Needs functions enabled for the app (else 403 functions_not_enabled) and the
587
- <code> TEST_SECRET</code> secret (<code>npx hawk-app secrets set TEST_SECRET</code>; else 500 secret_missing).
588
- </p>
589
- <label>
590
- Input <input value={text} onChange={(e) => setText(e.target.value)} />
591
- </label>{' '}
592
- <div className="buttons">
593
- <button disabled={busy} onClick={() => go(showcase)}>
594
- Run showcase
595
- </button>
596
- <button disabled={busy} onClick={() => go(async () => [await call('echo', 'echo', { text, at: new Date().toISOString() })])}>
597
- Call echo
598
- </button>
599
- <button disabled={busy} onClick={() => go(() => Promise.all([1, 2, 3, 4, 5, 6, 7].map((n) => call(`echo burst #${n}`, 'echo', { n }))))}>
600
- Burst ×7 (concurrency)
601
- </button>
602
- <button disabled={busy} onClick={() => go(async () => [await call('missing function (expect 404 function_not_found)', 'does-not-exist', {})])}>
603
- Error case
604
- </button>
605
- </div>
606
- <Results outcomes={outcomes} />
607
- </Card>
608
- );
609
- }
610
-
611
- /** A job as one result row. */
612
- function jobOutcome(label: string, job: FunctionJob<Record<string, unknown>>, ms: number): Outcome {
613
- const progress = job.progress ? ` · ${job.progress.percent ?? '?'}% ${job.progress.message ?? ''}` : '';
614
- return {
615
- label,
616
- ok: job.status === 'succeeded',
617
- ms,
618
- summary: `${job.status}${progress}${job.error ? ` · ${job.error.type}${job.error.message ? `: ${job.error.message}` : ''}` : ''}`,
619
- detail: job,
620
- };
621
- }
622
-
623
- function JobsTab({ app }: { app: HawkApp }) {
624
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
625
- const [live, setLive] = useState<FunctionJob | null>(null);
626
- const [schedules, setSchedules] = useState<AppSchedule[] | null>(null);
627
- const [busy, setBusy] = useState(false);
628
- const [steps, setSteps] = useState(10);
629
-
630
- const go = async (runs: () => Promise<Outcome[]>) => {
631
- setBusy(true);
632
- setOutcomes(await runs());
633
- setBusy(false);
634
- };
635
-
636
- // Start, then wait while showing progress live.
637
- const runJob = async (input: Record<string, unknown>, label: string): Promise<Outcome[]> => {
638
- const started = performance.now();
639
- try {
640
- const { jobId } = await app.functions.start('long-task', input);
641
- const job = await app.functions.wait<Record<string, unknown>>(jobId, { intervalMs: 2000, onProgress: (j) => setLive(j) });
642
- setLive(null);
643
- return [jobOutcome(label, job, Math.round(performance.now() - started))];
644
- } catch (e) {
645
- setLive(null);
646
- if (e instanceof FunctionJobError) {
647
- return [jobOutcome(`${label} (failed as a job)`, e.job as FunctionJob<Record<string, unknown>>, Math.round(performance.now() - started))];
648
- }
649
- return [{ label, ok: false, ms: Math.round(performance.now() - started), summary: describe(e) }];
650
- }
651
- };
652
-
653
- const listSchedules = () =>
654
- measure('schedules.list()', async () => {
655
- const list = await app.schedules.list();
656
- setSchedules(list);
657
- return { summary: `${list.length} schedules · ${list.filter((s) => s.enabled).length} on for this client`, detail: list };
658
- });
659
-
660
- const toggle = (schedule: AppSchedule) =>
661
- measure(`schedules.${schedule.enabled ? 'disable' : 'enable'}('${schedule.name}')`, async () => {
662
- const updated = schedule.enabled ? await app.schedules.disable(schedule.name) : await app.schedules.enable(schedule.name);
663
- return { summary: updated.enabled ? 'on' : 'off', detail: updated };
664
- });
665
-
666
- return (
667
- <>
668
- <Card title="Background jobs (functions:invoke, &quot;job&quot;: true)">
669
- <p className="muted">
670
- <code>functions/long-task.ts</code> counts through steps (3 s each), reporting progress, and returns what it knows about the run.
671
- Jobs run up to 15 minutes; 3 at once per app and client.
672
- </p>
673
- <div className="buttons">
674
- <label>
675
- Steps <input type="number" min={1} max={60} value={steps} onChange={(e) => setSteps(Number(e.target.value))} />
676
- </label>
677
- <button disabled={busy} onClick={() => go(() => runJob({ steps }, `long-task (${steps} steps)`))}>
678
- Start and wait
679
- </button>
680
- <button disabled={busy} onClick={() => go(() => runJob({ steps: 2, fail: true }, 'long-task failing (expect failed)'))}>
681
- Failing job
682
- </button>
683
- <button
684
- disabled={busy}
685
- onClick={() =>
686
- go(async () => [
687
- await expectError("functions.call('long-task') (expect 409 function_is_job)", 409, 'function_is_job', () => app.functions.call('long-task', {})),
688
- await expectError("functions.start('echo') (expect 409 function_not_job)", 409, 'function_not_job', () => app.functions.start('echo', {})),
689
- ])
690
- }
691
- >
692
- Error cases
693
- </button>
694
- </div>
695
- {live && (
696
- <p>
697
- <progress max={100} value={live.progress?.percent ?? 0} /> {live.status} · {live.progress?.percent ?? 0}% {live.progress?.message ?? ''}
698
- </p>
699
- )}
700
- <Results outcomes={outcomes} />
701
- </Card>
702
- <Card title="Schedules">
703
- <p className="muted">
704
- <code>daily-check</code> runs long-task at 13:00 America/Chicago for each client where it is on (as <code>schedule:daily-check</code>, on this
705
- site, with this client&apos;s HawkSearch connection). Approved schedules only.
706
- </p>
707
- <div className="buttons">
708
- <button disabled={busy} onClick={() => go(async () => [await listSchedules()])}>
709
- List
710
- </button>
711
- </div>
712
- {schedules && (
713
- <table className="params">
714
- <thead>
715
- <tr>
716
- <th>Name</th>
717
- <th>Runs</th>
718
- <th>For this client</th>
719
- <th>Last run</th>
720
- <th />
721
- </tr>
722
- </thead>
723
- <tbody>
724
- {schedules.map((s) => (
725
- <tr key={s.name}>
726
- <td>{s.name}</td>
727
- <td>
728
- {s.function} · <code>{s.cron}</code> ({s.timezone})
729
- </td>
730
- <td>{s.enabled ? `on (by ${s.enabledBy?.email ?? s.enabledBy?.id ?? '?'})` : 'off'}</td>
731
- <td>{s.lastRun ? `${new Date(s.lastRun.at).toLocaleString()} · ${s.lastRun.status}${s.lastRun.reason ? ` (${s.lastRun.reason})` : ''}` : '—'}</td>
732
- <td>
733
- <button disabled={busy} onClick={() => go(async () => [await toggle(s), await listSchedules()])}>
734
- {s.enabled ? 'Turn off' : 'Turn on'}
735
- </button>
736
- </td>
737
- </tr>
738
- ))}
739
- </tbody>
740
- </table>
741
- )}
742
- </Card>
743
- </>
744
- );
745
- }
746
-
747
- /** The names declared in hawk-app.json "systemParameters" (only these can be read or set). */
748
- const DECLARED_SYSTEM_PARAMETERS = ['Timezone', 'SearchPreviewURL'];
749
-
750
- function SystemParametersTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
751
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
752
- const [parameters, setParameters] = useState<SystemParameter[] | null>(null);
753
- const [busy, setBusy] = useState(false);
754
- const [name, setName] = useState(DECLARED_SYSTEM_PARAMETERS[0]);
755
- const [value, setValue] = useState('');
756
-
757
- const go = async (runs: () => Promise<Outcome[]>) => {
758
- setBusy(true);
759
- setOutcomes(await runs());
760
- setBusy(false);
761
- };
762
-
763
- const list = () =>
764
- measure('hawksearch.systemParameters.list()', async () => {
765
- const result = await app.hawksearch.systemParameters.list();
766
- setParameters(result.parameters);
767
- return { summary: `engine ${result.engineId} · ${result.parameters.length} parameters`, detail: result };
768
- });
769
-
770
- const set = () =>
771
- measure(`hawksearch.systemParameters.set('${name}', ${JSON.stringify(value)})`, async () => {
772
- const result = await app.hawksearch.systemParameters.set(name, value === '' ? null : value);
773
- return {
774
- summary: `${JSON.stringify(result.previous)} → ${JSON.stringify(result.value)} · applies within ${result.appliesWithinSeconds} s`,
775
- detail: result,
776
- };
777
- });
778
-
779
- const undeclared = () =>
780
- measure("hawksearch.systemParameters.set('WebsiteUrl') (undeclared: expect 404 parameter_not_found)", async () => {
781
- await app.hawksearch.systemParameters.set('WebsiteUrl', 'https://example.com');
782
- return { summary: 'unexpectedly allowed' };
783
- });
784
-
785
- return (
786
- <Card title="HawkSearch system parameters (hawksearch:sysparams:write)">
787
- <p className="muted">
788
- Engine {context.engine ? `${context.engine.id}${context.engine.name ? ` (${context.engine.name})` : ''}` : '(none: expect 400 engine_required)'}. Declared in{' '}
789
- <code>hawk-app.json</code>: {DECLARED_SYSTEM_PARAMETERS.join(', ')}. Changes apply in HawkSearch within about 3 minutes.
790
- </p>
791
- <div className="buttons">
792
- <button disabled={busy} onClick={() => go(async () => [await list()])}>
793
- List
794
- </button>
795
- </div>
796
- {parameters && (
797
- <table className="params">
798
- <thead>
799
- <tr>
800
- <th>Name</th>
801
- <th>Label</th>
802
- <th>Type</th>
803
- <th>Value</th>
804
- <th>Read-only</th>
805
- </tr>
806
- </thead>
807
- <tbody>
808
- {parameters.map((p) => (
809
- <tr key={p.name}>
810
- <td>{p.name}</td>
811
- <td>{p.label ?? ''}</td>
812
- <td>{p.type}</td>
813
- <td>{p.encrypted ? '(encrypted)' : p.value ?? <span className="muted">(not set)</span>}</td>
814
- <td>{p.readOnly ? 'yes' : 'no'}</td>
815
- </tr>
816
- ))}
817
- </tbody>
818
- </table>
819
- )}
820
- <div className="buttons">
821
- <select value={name} onChange={(e) => setName(e.target.value)}>
822
- {DECLARED_SYSTEM_PARAMETERS.map((n) => (
823
- <option key={n}>{n}</option>
824
- ))}
825
- </select>
826
- <input placeholder="value (empty clears it)" value={value} onChange={(e) => setValue(e.target.value)} />
827
- <button disabled={busy} onClick={() => go(async () => [await set(), await list()])}>
828
- Set
829
- </button>
830
- <button disabled={busy} onClick={() => go(async () => [await undeclared()])}>
831
- Error case
832
- </button>
833
- </div>
834
- <Results outcomes={outcomes} />
835
- </Card>
836
- );
837
- }
838
-
839
- function AiTab({ app }: { app: HawkApp }) {
840
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
841
- const [busy, setBusy] = useState(false);
842
- const [model, setModel] = useState<AiModel>('fast');
843
- const [system, setSystem] = useState('');
844
- const [prompt, setPrompt] = useState('In two sentences, what is site search merchandising?');
845
- const [output, setOutput] = useState('');
846
- const [info, setInfo] = useState<AiInfo | null>(null);
847
- // Stops the running call (the stream's abort(), or generate's AbortSignal).
848
- const stop = useRef<(() => void) | null>(null);
849
-
850
- const request = () => ({ model, prompt, ...(system.trim() ? { system } : {}) });
851
- const record = (outcome: Outcome) => setOutcomes((previous) => [outcome, ...previous].slice(0, 10));
852
- const summarize = (result: AiResult) => `${result.model} · ${result.stopReason} · ${result.usage.inputTokens} in / ${result.usage.outputTokens} out · id ${result.id}`;
853
-
854
- const stream = async () => {
855
- setBusy(true);
856
- setOutput('');
857
- const s = app.ai.stream(request());
858
- stop.current = () => s.abort();
859
- record(
860
- await measure(`ai.stream({ model: '${model}' })`, async () => {
861
- let chunks = 0;
862
- for await (const text of s) {
863
- chunks++;
864
- setOutput((previous) => previous + text);
865
- }
866
- const result = await s.result;
867
- return { summary: `${chunks} chunks · ${summarize(result)}`, detail: result };
868
- }),
869
- );
870
- stop.current = null;
871
- setBusy(false);
872
- };
873
-
874
- const generate = async () => {
875
- setBusy(true);
876
- setOutput('');
877
- const controller = new AbortController();
878
- stop.current = () => controller.abort();
879
- record(
880
- await measure(`ai.generate({ model: '${model}' })`, async () => {
881
- const result = await app.ai.generate(request(), { signal: controller.signal });
882
- setOutput(result.text);
883
- return { summary: summarize(result), detail: result };
884
- }),
885
- );
886
- stop.current = null;
887
- setBusy(false);
888
- };
889
-
890
- const loadInfo = async () =>
891
- record(
892
- await measure('ai.info()', async () => {
893
- const result = await app.ai.info();
894
- setInfo(result);
895
- return { summary: result.tiers.map((t) => `${t.name} ${t.usedTokens}/${t.dailyTokens}`).join(' · '), detail: result };
896
- }),
897
- );
898
-
899
- return (
900
- <Card title="AI (ai:invoke)">
901
- <p className="muted">
902
- Claude through the platform: <code>fast</code> (Haiku) or <code>smart</code> (Sonnet). Every call costs tokens from the tier's daily budget
903
- for this client; nothing is retried.
904
- </p>
905
- <div className="buttons">
906
- <select value={model} onChange={(e) => setModel(e.target.value as AiModel)}>
907
- <option value="fast">fast</option>
908
- <option value="smart">smart</option>
909
- </select>
910
- <button onClick={loadInfo}>Info</button>
911
- </div>
912
- {info && (
913
- <table className="params">
914
- <thead>
915
- <tr>
916
- <th>Tier</th>
917
- <th>Used today</th>
918
- <th>Daily tokens</th>
919
- <th>Max output tokens</th>
920
- </tr>
921
- </thead>
922
- <tbody>
923
- {info.tiers.map((t) => (
924
- <tr key={t.name}>
925
- <td>{t.name}</td>
926
- <td>{t.usedTokens.toLocaleString()}</td>
927
- <td>{t.dailyTokens.toLocaleString()}</td>
928
- <td>{t.maxOutputTokens.toLocaleString()}</td>
929
- </tr>
930
- ))}
931
- </tbody>
932
- </table>
933
- )}
934
- {info && (
935
- <p className="muted">
936
- Max input {info.maxInputBytes.toLocaleString()} bytes · {info.requestsPerMinute} requests per minute.
937
- </p>
938
- )}
939
- <textarea rows={2} placeholder="System prompt (optional)" value={system} onChange={(e) => setSystem(e.target.value)} />
940
- <textarea rows={4} placeholder="Prompt" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
941
- <div className="buttons">
942
- <button disabled={busy || !prompt.trim()} onClick={stream}>
943
- Stream
944
- </button>
945
- <button disabled={busy || !prompt.trim()} onClick={generate}>
946
- Generate
947
- </button>
948
- <button disabled={!busy} onClick={() => stop.current?.()}>
949
- Stop
950
- </button>
951
- </div>
952
- <textarea rows={10} readOnly value={output} placeholder="The answer appears here as it arrives." />
953
- <Results outcomes={outcomes} />
954
- </Card>
955
- );
956
- }
957
-
958
- /** Index rebuilds (hawksearch:index): the latest run, and a full rebuild followed to the end. */
959
- function IndexTab({ app }: { app: HawkApp }) {
960
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
961
- const [progress, setProgress] = useState('');
962
- const add = (o: Outcome) => setOutcomes((current) => [...current, o]);
963
- const describeRun = (r: IndexRun) => `${r.status}${r.done ? ' (done)' : ''} · ${r.count ?? 0} items${r.durationSeconds !== null ? ` · ${r.durationSeconds} s` : ''}`;
964
-
965
- const status = async () => {
966
- setOutcomes([]);
967
- add(await measure('index.status()', async () => {
968
- const run = await app.hawksearch.index.status();
969
- return { summary: describeRun(run), detail: run };
970
- }));
971
- };
972
-
973
- // The Indexing API's reads (engines with their own indexes; others answer 503 indexing_api_not_available).
974
- const indexes = async () => {
975
- setOutcomes([]);
976
- add(await measure('index.indexes()', async () => {
977
- const names = await app.hawksearch.index.indexes();
978
- return { summary: `${names.length} indexes`, detail: names };
979
- }));
980
- add(await measure('index.current()', async () => ({ summary: (await app.hawksearch.index.current()) ?? 'none' })));
981
- };
982
-
983
- const rebuild = async () => {
984
- setOutcomes([]);
985
- setProgress('');
986
- let id: number | null = null;
987
- add(await measure('index.rebuild()', async () => {
988
- const started = await app.hawksearch.index.rebuild();
989
- id = started.notificationId;
990
- return { summary: `#${started.notificationId} ${describeRun(started)}`, detail: started };
991
- }));
992
- const runId = id as number | null; // set inside the callback above
993
- if (runId !== null) {
994
- add(await measure(`index.wait(${runId})`, async () => {
995
- const final = await app.hawksearch.index.wait(runId, { intervalMs: 10_000, onProgress: (r) => setProgress(describeRun(r)) });
996
- return { summary: describeRun(final), detail: final };
997
- }));
998
- }
999
- };
1000
-
1001
- return (
1002
- <>
1003
- <Card title="Index rebuilds (hawksearch:index)">
1004
- <p className="muted">
1005
- A rebuild reloads this engine&apos;s live search from its feed. It needs the engine&apos;s EnableRebuildIndex on, and one runs at a time.
1006
- </p>
1007
- <button onClick={indexes}>Indexes</button> <button onClick={status}>Latest rebuild</button>{' '}
1008
- <button onClick={rebuild}>Rebuild now and wait</button>
1009
- {progress && <p className="muted">Now: {progress}</p>}
1010
- <Results outcomes={outcomes} />
1011
- </Card>
1012
- <IndexItemsCard app={app} />
1013
- </>
1014
- );
1015
- }
1016
-
1017
- /**
1018
- * Item writes (hawksearch:index:items): one field of one item, updated and read back through search, then put back to
1019
- * the value it had. The Indexing API applies writes at once: on the current index, that's the engine's live search.
1020
- */
1021
- function IndexItemsCard({ app }: { app: HawkApp }) {
1022
- const [indexNames, setIndexNames] = useState<string[]>([]);
1023
- const [indexName, setIndexName] = useState('');
1024
- const [primaryKey, setPrimaryKey] = useState('id');
1025
- const [itemId, setItemId] = useState('');
1026
- const [field, setField] = useState('');
1027
- const [value, setValue] = useState('');
1028
- const [understood, setUnderstood] = useState(false);
1029
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1030
- const add = (o: Outcome) => setOutcomes((current) => [...current, o]);
1031
- const ready = understood && indexName && primaryKey.trim() && itemId.trim() && field.trim();
1032
-
1033
- const load = async () => {
1034
- setOutcomes([]);
1035
- add(await measure('index.indexes() + current()', async () => {
1036
- const [names, current] = await Promise.all([app.hawksearch.index.indexes(), app.hawksearch.index.current()]);
1037
- setIndexNames(names);
1038
- setIndexName(current ?? names[0] ?? '');
1039
- return { summary: `${names.length} indexes, current: ${current ?? 'none'}`, detail: { names, current } };
1040
- }));
1041
- };
1042
-
1043
- // Each field is an array of values; the item is named by the engine's primary key (set in HawkSearch's fields).
1044
- const item = (fields: Record<string, unknown[]>) => ({ [primaryKey.trim()]: [itemId.trim()], ...fields });
1045
-
1046
- // The field's values before the first change, read through search (the live index), to put back afterwards.
1047
- const [previous, setPrevious] = useState<unknown[] | null>(null);
1048
-
1049
- /** The item's field as search sees it now (only the current index is searched). */
1050
- const read = (label: string) =>
1051
- measure(label, async () => {
1052
- const found = await app.hawksearch.search({ Keyword: itemId.trim(), MaxPerPage: 1 });
1053
- const values = found.Results?.[0]?.Document?.[field.trim()];
1054
- return { summary: `${found.Pagination?.NofResults ?? 0} results · ${field}: ${JSON.stringify(values ?? null)}`, detail: { values, values_raw: found.Results?.[0] } };
1055
- });
1056
-
1057
- const write = (values: unknown[]) =>
1058
- measure(`index.items.update(${itemId}: ${field} = ${JSON.stringify(values)})`, async () => {
1059
- const result: IndexItemsResult = await app.hawksearch.index.items.update([item({ [field.trim()]: values })], { indexName });
1060
- return { summary: `${result.Status ?? '?'} · ${result.Summary?.Succeeded ?? 0} of ${result.Summary?.Total ?? 0} updated`, detail: result };
1061
- });
1062
-
1063
- const update = async () => {
1064
- setOutcomes([]);
1065
- if (previous === null) {
1066
- const before = await app.hawksearch.search({ Keyword: itemId.trim(), MaxPerPage: 1 }).catch(() => null);
1067
- const values = before?.Results?.[0]?.Document?.[field.trim()];
1068
- setPrevious(Array.isArray(values) ? values : []);
1069
- }
1070
- add(await write([value]));
1071
- add(await read(`search for ${itemId} (after)`));
1072
- };
1073
-
1074
- const restore = async () => {
1075
- setOutcomes([]);
1076
- add(await write(previous ?? []));
1077
- add(await read(`search for ${itemId} (restored)`));
1078
- setPrevious(null);
1079
- };
1080
-
1081
- // Expected to be refused: the platform never deletes the current index (needs hawksearch:index:manage, which this
1082
- // app doesn't ask for, so it shows the scope check instead).
1083
- const deleteCurrent = async () => {
1084
- setOutcomes([]);
1085
- add(await measure('index.deleteIndex(current) (expect 403 scope_missing)', async () => {
1086
- await app.hawksearch.index.deleteIndex(indexName || 'x');
1087
- return { summary: 'unexpectedly allowed' };
1088
- }));
1089
- };
1090
-
1091
- return (
1092
- <Card title="Item writes (hawksearch:index:items)">
1093
- <p className="muted">
1094
- Writes go straight into the chosen index. On the current index that&apos;s this engine&apos;s live search: use a test engine and a test item.
1095
- </p>
1096
- <div className="buttons">
1097
- <button onClick={load}>Load indexes</button>
1098
- <select value={indexName} onChange={(e) => setIndexName(e.target.value)} disabled={!indexNames.length}>
1099
- {indexNames.map((n) => (
1100
- <option key={n}>{n}</option>
1101
- ))}
1102
- </select>
1103
- </div>
1104
- <div className="buttons">
1105
- <label>
1106
- Primary key <input value={primaryKey} onChange={(e) => setPrimaryKey(e.target.value)} size={8} />
1107
- </label>
1108
- <label>
1109
- Item <input value={itemId} onChange={(e) => setItemId(e.target.value)} placeholder="e.g. sku-1" />
1110
- </label>
1111
- <label>
1112
- Field <input value={field} onChange={(e) => setField(e.target.value)} placeholder="a field defined in HawkSearch" />
1113
- </label>
1114
- <label>
1115
- Value <input value={value} onChange={(e) => setValue(e.target.value)} />
1116
- </label>
1117
- </div>
1118
- <label>
1119
- <input type="checkbox" checked={understood} onChange={(e) => setUnderstood(e.target.checked)} /> I&apos;m on a test engine: change this item now
1120
- </label>
1121
- <div className="buttons">
1122
- <button onClick={update} disabled={!ready}>
1123
- Update the field
1124
- </button>
1125
- <button onClick={restore} disabled={!ready || previous === null}>
1126
- Put the old value back{previous !== null ? ` (${JSON.stringify(previous)})` : ''}
1127
- </button>
1128
- <button onClick={deleteCurrent}>Try deleting the index</button>
1129
- </div>
1130
- <Results outcomes={outcomes} />
1131
- </Card>
1132
- );
1133
- }
1134
-
1135
- /** Recommendations (hawksearch:recommendations): one widget, for an optional product. */
1136
- /** app.config: the values declared in "config" in hawk-app.json, set with `hawk-app config set` or in the portal. */
1137
- function ConfigTab({ app }: { app: HawkApp }) {
1138
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1139
-
1140
- const check = async () => {
1141
- const results: Outcome[] = [];
1142
- results.push(
1143
- await measure('config.all()', async () => {
1144
- const all = await app.config.all();
1145
- return { summary: `${Object.keys(all).length} keys: ${Object.keys(all).join(', ') || '(none declared)'}`, detail: all };
1146
- }),
1147
- );
1148
- results.push(
1149
- await measure("config.get('showcaseGreeting')", async () => {
1150
- const greeting = await app.config.get<string>('showcaseGreeting');
1151
- return { summary: greeting === null ? 'null (not set, no default)' : JSON.stringify(greeting) };
1152
- }),
1153
- );
1154
- results.push(
1155
- await measure("config.get('showcaseNotifyEmails')", async () => {
1156
- const emails = await app.config.get<string[]>('showcaseNotifyEmails');
1157
- return { summary: emails === null ? 'null (set it with hawk-app config set showcaseNotifyEmails a@b.com)' : `${emails.length} addresses`, detail: emails };
1158
- }),
1159
- );
1160
- results.push(
1161
- await measure("config.get('notDeclared')", async () => {
1162
- const value = await app.config.get('notDeclared');
1163
- if (value !== null) throw new Error(`expected null, got ${JSON.stringify(value)}`);
1164
- return { summary: 'null, as expected' };
1165
- }),
1166
- );
1167
- results.push(
1168
- await measure("config.get('') (refused before calling)", async () => {
1169
- let refused = false;
1170
- try {
1171
- await app.config.get('');
1172
- } catch (e) {
1173
- refused = e instanceof TypeError;
1174
- }
1175
- if (!refused) throw new Error('unexpectedly allowed');
1176
- return { summary: 'TypeError, as expected' };
1177
- }),
1178
- );
1179
- results.push(
1180
- await measure("functions.call('echo').config", async () => {
1181
- const echoed = await app.functions.call<{ config?: Record<string, unknown> }>('echo', { from: 'config tab' });
1182
- return { summary: `${Object.keys(echoed.config ?? {}).length} keys in ctx.config`, detail: echoed.config };
1183
- }),
1184
- );
1185
- setOutcomes(results);
1186
- };
1187
-
1188
- return (
1189
- <Card title="App config">
1190
- <p className="muted">
1191
- Values for the keys in "config" in hawk-app.json: this client's value, else the global one, else the default. Set them with{' '}
1192
- <code>npx hawk-app config set showcaseGreeting "Hi" [--client &lt;id&gt;]</code> or in the Hawk Analytics portal; they apply within about 30
1193
- seconds. Not secret: they reach the browser.
1194
- </p>
1195
- <button onClick={check}>Run</button>
1196
- <Results outcomes={outcomes} />
1197
- </Card>
1198
- );
1199
- }
1200
-
1201
- function StorageTab({ app }: { app: HawkApp }) {
1202
- const [notes, setNotes] = useState<StorageListEntry<{ text: string; at: string }>[]>([]);
1203
- const [text, setText] = useState('');
1204
- const [usage, setUsage] = useState<StorageUsage | null>(null);
1205
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1206
- const record = (outcome: Outcome) => setOutcomes((previous) => [outcome, ...previous].slice(0, 20));
1207
-
1208
- // Notes are shared by everyone at this client who opens the showcase: keys note#<time>, so they list in order.
1209
- const refresh = async () =>
1210
- record(
1211
- await measure('storage.list(note#) + usage()', async () => {
1212
- const page = await app.storage.list<{ text: string; at: string }>({ prefix: 'note#', values: true, limit: 50 });
1213
- setNotes(page.items);
1214
- setUsage(await app.storage.usage());
1215
- return { summary: `${page.items.length} notes${page.cursor ? ' (more)' : ''}`, detail: page };
1216
- }),
1217
- );
1218
-
1219
- const add = async () => {
1220
- const at = new Date().toISOString();
1221
- record(
1222
- await measure('storage.put(note#…, ttlDays 7)', async () => {
1223
- const result = await app.storage.put(`note#${at}`, { text: text.trim(), at }, { ttlDays: 7, ifVersion: 0 });
1224
- return { summary: `v${result.version}, expires ${result.expiresAt?.slice(0, 10)}`, detail: result };
1225
- }),
1226
- );
1227
- setText('');
1228
- await refresh();
1229
- };
1230
-
1231
- const remove = async (key: string, version: number) => {
1232
- record(await measure(`storage.delete(${key})`, async () => (await app.storage.delete(key, { ifVersion: version }), { summary: 'deleted' })));
1233
- await refresh();
1234
- };
1235
-
1236
- /** Every storage call once, on keys of its own (check#…), cleaned up afterwards. */
1237
- const check = async () => {
1238
- const key = `check#${Date.now()}`;
1239
- const results: Outcome[] = [];
1240
- results.push(await measure('put (new, ifVersion 0)', async () => ({ summary: `v${(await app.storage.put(key, { n: 1 }, { ifVersion: 0 })).version}` })));
1241
- results.push(await measure('get', async () => ({ summary: JSON.stringify((await app.storage.get(key))?.value) })));
1242
- results.push(await expectError('put with a stale ifVersion', 409, 'version_conflict', () => app.storage.put(key, { n: 2 }, { ifVersion: 0 })));
1243
- results.push(await measure('put (ifVersion 1)', async () => ({ summary: `v${(await app.storage.put(key, { n: 2 }, { ifVersion: 1 })).version}` })));
1244
- results.push(
1245
- await measure('batch (get, put, delete)', async () => {
1246
- const batch = await app.storage.batch({ get: [key, `${key}-missing`], put: [{ key: `${key}-b`, value: 'b' }], delete: [`${key}-b`] });
1247
- return { summary: `get ${batch.get.map((g) => (g.item ? 'found' : 'missing')).join('/')}, put ${batch.put[0].status}, delete ${batch.delete[0].status}`, detail: batch };
1248
- }),
1249
- );
1250
- results.push(await measure('list (prefix)', async () => ({ summary: `${(await app.storage.list({ prefix: key })).items.length} item` })));
1251
- results.push(await measure('delete', async () => (await app.storage.delete(key), { summary: 'deleted' })));
1252
- results.push(await measure('get (deleted)', async () => ({ summary: (await app.storage.get(key)) === null ? 'null, as expected' : 'still there!' })));
1253
- results.push(
1254
- await measure('invalid key #usage (refused before calling)', async () => {
1255
- const refused = await app.storage.get('#usage').then(() => false, (e) => e instanceof TypeError);
1256
- if (!refused) throw new Error('unexpectedly allowed');
1257
- return { summary: 'TypeError, as expected' };
1258
- }),
1259
- );
1260
- setOutcomes(results);
1261
- setUsage(await app.storage.usage());
1262
- };
1263
-
1264
- return (
1265
- <>
1266
- <Card title="Notes (storage)">
1267
- <p className="muted">Shared by everyone at this client using the showcase. Each note expires after 7 days.</p>
1268
- <input value={text} onChange={(e) => setText(e.target.value)} placeholder="A note" />{' '}
1269
- <button onClick={add} disabled={!text.trim()}>
1270
- Add
1271
- </button>{' '}
1272
- <button onClick={refresh}>Refresh</button>
1273
- <ul>
1274
- {notes.map((n) => (
1275
- <li key={n.key}>
1276
- {n.value?.text} <span className="muted">· {n.value?.at.slice(0, 16).replace('T', ' ')} · v{n.version}</span>{' '}
1277
- <button onClick={() => remove(n.key, n.version)}>Delete</button>
1278
- </li>
1279
- ))}
1280
- </ul>
1281
- {usage && (
1282
- <p className="muted">
1283
- Usage: {usage.items} / {usage.maxItems} items, {(usage.bytes / 1024).toFixed(1)} KB / {Math.round(usage.maxBytes / 1048576)} MB
1284
- </p>
1285
- )}
1286
- </Card>
1287
- <Card title="Check every call">
1288
- <button onClick={check}>Run</button>
1289
- <Results outcomes={outcomes} />
1290
- </Card>
1291
- <StorageFilesCard app={app} />
1292
- </>
1293
- );
1294
- }
1295
-
1296
- /** storage.files: upload a file (kept 7 days), open or delete it, and a round trip through every call. */
1297
- function StorageFilesCard({ app }: { app: HawkApp }) {
1298
- const [files, setFiles] = useState<StorageFileEntry[]>([]);
1299
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1300
- const record = (outcome: Outcome) => setOutcomes((previous) => [outcome, ...previous].slice(0, 20));
1301
-
1302
- const refresh = async () =>
1303
- record(
1304
- await measure('files.list(uploads/)', async () => {
1305
- const page = await app.storage.files.list({ prefix: 'uploads/' });
1306
- setFiles(page.files);
1307
- return { summary: `${page.files.length} files${page.cursor ? ' (more)' : ''}`, detail: page };
1308
- }),
1309
- );
1310
-
1311
- const upload = async (file: File) => {
1312
- // Paths allow A–Z a–z 0–9 . _ - only.
1313
- const path = `uploads/${Date.now()}-${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}`;
1314
- record(
1315
- await measure(`files.upload(${path})`, async () => {
1316
- const result = await app.storage.files.upload(path, file, { ttlDays: 7 });
1317
- return { summary: `${result.size} bytes, ${result.contentType}`, detail: result };
1318
- }),
1319
- );
1320
- await refresh();
1321
- };
1322
-
1323
- const open = async (path: string) =>
1324
- record(
1325
- await measure(`files.url(${path})`, async () => {
1326
- const { url, expiresAt } = await app.storage.files.url(path, { download: path.split('/').pop() });
1327
- await app.navigate({ intent: 'external', url });
1328
- return { summary: `opened, link works until ${expiresAt.slice(11, 16)} UTC` };
1329
- }),
1330
- );
1331
-
1332
- const remove = async (path: string) => {
1333
- record(await measure(`files.delete(${path})`, async () => (await app.storage.files.delete(path), { summary: 'deleted' })));
1334
- await refresh();
1335
- };
1336
-
1337
- /** upload → url → download (same bytes back) → list → delete → url is 404, on a path of its own. */
1338
- const check = async () => {
1339
- const path = `checks/${Date.now()}.txt`;
1340
- const text = `showcase check ${new Date().toISOString()}`;
1341
- const results: Outcome[] = [];
1342
- results.push(await measure('upload (text, ttlDays 1)', async () => ({ summary: `${(await app.storage.files.upload(path, text, { ttlDays: 1 })).size} bytes` })));
1343
- results.push(await measure('url', async () => ({ summary: `expires ${(await app.storage.files.url(path)).expiresAt}` })));
1344
- results.push(
1345
- await measure('download (same bytes back)', async () => {
1346
- const back = await (await app.storage.files.download(path)).text();
1347
- if (back !== text) throw new Error(`got ${JSON.stringify(back)}`);
1348
- return { summary: 'matches' };
1349
- }),
1350
- );
1351
- results.push(await measure('list (prefix)', async () => ({ summary: `${(await app.storage.files.list({ prefix: path })).files.length} file` })));
1352
- results.push(await measure('delete', async () => (await app.storage.files.delete(path), { summary: 'deleted' })));
1353
- results.push(await expectError('url after delete', 404, 'not_found', () => app.storage.files.url(path)));
1354
- results.push(
1355
- await measure('usage', async () => {
1356
- const usage = await app.storage.usage();
1357
- return { summary: `${usage.files} files, ${(usage.fileBytes / 1024).toFixed(1)} KB of ${Math.round(usage.maxFileBytes / 1073741824)} GB` };
1358
- }),
1359
- );
1360
- setOutcomes(results);
1361
- };
1362
-
1363
- return (
1364
- <Card title="Files (storage.files)">
1365
- <p className="muted">Uploads are shared by everyone at this client and deleted after 7 days.</p>
1366
- <input type="file" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} /> <button onClick={refresh}>Refresh</button>{' '}
1367
- <button onClick={check}>Check every call</button>
1368
- <ul>
1369
- {files.map((f) => (
1370
- <li key={f.path}>
1371
- {f.path} <span className="muted">· {(f.size / 1024).toFixed(1)} KB</span> <button onClick={() => open(f.path)}>Open</button>{' '}
1372
- <button onClick={() => remove(f.path)}>Delete</button>
1373
- </li>
1374
- ))}
1375
- </ul>
1376
- <Results outcomes={outcomes} />
1377
- </Card>
1378
- );
1379
- }
1380
-
1381
- function RecommendationsTab({ app }: { app: HawkApp }) {
1382
- const [widgetGuid, setWidgetGuid] = useState('');
1383
- const [uniqueId, setUniqueId] = useState('');
1384
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1385
-
1386
- const run = async () =>
1387
- setOutcomes([
1388
- await measure('hawksearch.recommendations()', async () => {
1389
- const response = await app.hawksearch.recommendations({ widgetUids: [{ widgetGuid: widgetGuid.trim(), uniqueId: uniqueId.trim() || null }] });
1390
- const items = response.widgetItems?.flatMap((w) => w.recommendationItems ?? []) ?? [];
1391
- return { summary: `${response.widgetItems?.length ?? 0} widgets, ${items.length} items`, detail: response };
1392
- }),
1393
- ]);
1394
-
1395
- return (
1396
- <Card title="Recommendations (hawksearch:recommendations)">
1397
- <label>
1398
- Widget guid <input value={widgetGuid} onChange={(e) => setWidgetGuid(e.target.value)} placeholder="from HawkSearch's Recommendations page" />
1399
- </label>{' '}
1400
- <label>
1401
- Product id <input value={uniqueId} onChange={(e) => setUniqueId(e.target.value)} placeholder="optional" />
1402
- </label>{' '}
1403
- <button onClick={run} disabled={!widgetGuid.trim()}>
1404
- Get
1405
- </button>
1406
- <Results outcomes={outcomes} />
1407
- </Card>
1408
- );
1409
- }
1410
-
1411
- function HostTab({ app }: { app: HawkApp }) {
1412
- const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1413
- const [tall, setTall] = useState(false);
1414
- const record = async (label: string, call: () => Promise<void>) =>
1415
- setOutcomes([await measure(label, async () => (await call(), { summary: 'sent' })), ...outcomes].slice(0, 10));
1416
-
1417
- return (
1418
- <Card title="Host messages">
1419
- <div className="buttons">
1420
- {(['info', 'success', 'warning', 'error'] as const).map((variant) => (
1421
- <button key={variant} onClick={() => record(`toast ${variant}`, () => app.toast(`Showcase: ${variant} toast`, variant))}>
1422
- Toast {variant}
1423
- </button>
1424
- ))}
1425
- <button onClick={() => record('setTitle', () => app.setTitle(`Showcase · ${new Date().toLocaleTimeString()}`))}>Set title</button>
1426
- <button onClick={() => record('navigate settings', () => app.navigate({ intent: 'settings' }))}>Navigate: settings</button>
1427
- <button onClick={() => record('navigate external', () => app.navigate({ intent: 'external', url: 'https://www.hawksearch.com/' }))}>
1428
- Navigate: external
1429
- </button>
1430
- <button onClick={() => setTall(!tall)}>{tall ? 'Shrink' : 'Grow'} (auto-resize)</button>
1431
- </div>
1432
- {tall && <div className="tall">Tall content: the frame should grow to fit.</div>}
1433
- <Results outcomes={outcomes} />
1434
- </Card>
1435
- );
1436
- }
1
+ import {
2
+ FunctionJobError,
3
+ HawkApiError,
4
+ type AiInfo,
5
+ type AiModel,
6
+ type AiResult,
7
+ type AppSchedule,
8
+ type FunctionJob,
9
+ type HawkApp,
10
+ type HawkAppContext,
11
+ type IndexItemsResult,
12
+ type IndexRun,
13
+ type SearchRequest,
14
+ type SearchResponse,
15
+ type StorageFileEntry,
16
+ type StorageListEntry,
17
+ type StorageUsage,
18
+ type SystemParameter,
19
+ } from '@bridgeline-digital/hawk-app-bridge/client';
20
+ import { useRef, useState, type ReactNode } from 'react';
21
+ import { useHawkApp } from './useHawkApp';
22
+
23
+ /**
24
+ * Internal test app for the Hawk Apps platform. One tab per feature; every call shows its outcome, timing and error
25
+ * code, so a failing platform change is easy to spot. Not a product: keep it small and keep it working.
26
+ */
27
+ const TABS = ['Context', 'Analytics', 'Dashboard', 'Search', 'Recommendations', 'Index', 'Storage', 'Config', 'Queries', 'Functions', 'Jobs', 'System parameters', 'AI', 'Host'] as const;
28
+ type Tab = (typeof TABS)[number];
29
+
30
+ export function App() {
31
+ const hawk = useHawkApp();
32
+ const [tab, setTab] = useState<Tab>('Context');
33
+
34
+ if (hawk.status === 'connecting') return <p className="muted">Connecting…</p>;
35
+ if (hawk.status === 'error') return <p className="error">{hawk.message}</p>;
36
+
37
+ return (
38
+ <main>
39
+ <h2>Hawk Apps Showcase</h2>
40
+ <nav className="tabs">
41
+ {TABS.map((t) => (
42
+ <button key={t} className={t === tab ? 'active' : ''} onClick={() => setTab(t)}>
43
+ {t}
44
+ </button>
45
+ ))}
46
+ </nav>
47
+ {tab === 'Context' && <ContextTab context={hawk.context} />}
48
+ {tab === 'Analytics' && <AnalyticsTab app={hawk.app} context={hawk.context} />}
49
+ {tab === 'Dashboard' && <DashboardTab app={hawk.app} />}
50
+ {tab === 'Search' && <SearchTab app={hawk.app} context={hawk.context} />}
51
+ {tab === 'Recommendations' && <RecommendationsTab app={hawk.app} />}
52
+ {tab === 'Index' && <IndexTab app={hawk.app} />}
53
+ {tab === 'Storage' && <StorageTab app={hawk.app} />}
54
+ {tab === 'Config' && <ConfigTab app={hawk.app} />}
55
+ {tab === 'Queries' && <QueriesTab app={hawk.app} context={hawk.context} />}
56
+ {tab === 'Functions' && <FunctionsTab app={hawk.app} context={hawk.context} />}
57
+ {tab === 'Jobs' && <JobsTab app={hawk.app} />}
58
+ {tab === 'System parameters' && <SystemParametersTab app={hawk.app} context={hawk.context} />}
59
+ {tab === 'AI' && <AiTab app={hawk.app} />}
60
+ {tab === 'Host' && <HostTab app={hawk.app} />}
61
+ </main>
62
+ );
63
+ }
64
+
65
+ // ---------- shared ----------
66
+
67
+ interface Outcome {
68
+ label: string;
69
+ ok: boolean;
70
+ ms: number;
71
+ summary: string;
72
+ detail?: unknown;
73
+ }
74
+
75
+ /** Runs a call and records how it went: never throws, so one failure doesn't hide the others. */
76
+ async function measure(label: string, call: () => Promise<{ summary: string; detail?: unknown }>): Promise<Outcome> {
77
+ const started = performance.now();
78
+ try {
79
+ const { summary, detail } = await call();
80
+ return { label, ok: true, ms: Math.round(performance.now() - started), summary, detail };
81
+ } catch (e) {
82
+ return { label, ok: false, ms: Math.round(performance.now() - started), summary: describe(e) };
83
+ }
84
+ }
85
+
86
+ function describe(e: unknown): string {
87
+ if (e instanceof HawkApiError) return `${e.status}${e.code ? ` ${e.code}` : ''}: ${e.message}`;
88
+ return e instanceof Error ? e.message : String(e);
89
+ }
90
+
91
+ function Results({ outcomes }: { outcomes: Outcome[] }) {
92
+ if (!outcomes.length) return null;
93
+ return (
94
+ <div className="results">
95
+ {outcomes.map((o, i) => {
96
+ const line = (
97
+ <>
98
+ <span className="badge">{o.ok ? 'OK' : 'FAIL'}</span> {o.label} · {o.ms} ms · {o.summary}
99
+ </>
100
+ );
101
+ // Only results with something to show expand; the rest are a plain line (no empty arrow).
102
+ return o.detail === undefined || o.detail === null ? (
103
+ <div key={i} className={`result ${o.ok ? 'ok' : 'fail'}`}>
104
+ {line}
105
+ </div>
106
+ ) : (
107
+ <details key={i} className={o.ok ? 'ok' : 'fail'}>
108
+ <summary>{line}</summary>
109
+ <pre>{typeof o.detail === 'string' ? o.detail : JSON.stringify(o.detail, null, 2)}</pre>
110
+ </details>
111
+ );
112
+ })}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function Card({ title, children }: { title: string; children: ReactNode }) {
118
+ return (
119
+ <section className="card">
120
+ <h3>{title}</h3>
121
+ {children}
122
+ </section>
123
+ );
124
+ }
125
+
126
+ /** The host's date range as YYYY-MM-DD (the last 30 days when the host sends none). */
127
+ function range(context: HawkAppContext): { from: string; to: string } {
128
+ if (context.dateRange) return { from: context.dateRange.from.slice(0, 10), to: context.dateRange.to.slice(0, 10) };
129
+ const to = new Date();
130
+ const from = new Date(to.getTime() - 29 * 86_400_000);
131
+ return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
132
+ }
133
+
134
+ /** The rows shown under a result: all of them up to 100, so a result can be checked in full. */
135
+ const PREVIEW_ROWS = 100;
136
+ const preview = <T,>(rows: T[]) => rows.slice(0, PREVIEW_ROWS);
137
+ const shown = (rows: unknown[]) => (rows.length > PREVIEW_ROWS ? ` (showing ${PREVIEW_ROWS})` : '');
138
+
139
+ // ---------- tabs ----------
140
+
141
+ function ContextTab({ context }: { context: HawkAppContext }) {
142
+ return (
143
+ <Card title="Context from the host">
144
+ <p className="muted">Everything the host sent in the handshake (and later context changes).</p>
145
+ <pre>{JSON.stringify(context, null, 2)}</pre>
146
+ </Card>
147
+ );
148
+ }
149
+
150
+ function AnalyticsTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
151
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
152
+ const [busy, setBusy] = useState(false);
153
+ const reports = ['searches', 'queries', 'assistant-prompts'];
154
+
155
+ const run = async () => {
156
+ setBusy(true);
157
+ const results = await Promise.all(
158
+ reports.map((name) =>
159
+ measure(`analytics.report('${name}')`, async () => {
160
+ const report = await app.analytics.report(name, { limit: 10 });
161
+ return { summary: `${report.data.length} rows (total ${report.totalRecords ?? '?'})${shown(report.data)}`, detail: preview(report.data) };
162
+ }),
163
+ ),
164
+ );
165
+ setOutcomes(results);
166
+ setBusy(false);
167
+ };
168
+
169
+ return (
170
+ <Card title="Analytics API (reports:read)">
171
+ <p className="muted">
172
+ Dates default to the host range ({range(context).from} – {range(context).to}). Reports: {reports.join(', ')}.
173
+ </p>
174
+ <button disabled={busy} onClick={run}>
175
+ Run reports
176
+ </button>
177
+ <Results outcomes={outcomes} />
178
+ </Card>
179
+ );
180
+ }
181
+
182
+ function DashboardTab({ app }: { app: HawkApp }) {
183
+ const [path, setPath] = useState('Synonym');
184
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
185
+
186
+ const run = async () =>
187
+ setOutcomes([
188
+ await measure(`dashboard.get('${path}')`, async () => {
189
+ const data = await app.dashboard.get<unknown>(path);
190
+ const rows = Array.isArray(data) ? data : null;
191
+ return { summary: rows ? `${rows.length} items${shown(rows)}` : 'object', detail: rows ? preview(rows) : data };
192
+ }),
193
+ // Versioned paths must be rejected: the platform picks the API version.
194
+ await measure(`dashboard.get('v11/${path}') (expect 400)`, async () => {
195
+ await app.dashboard.get(`v11/${path}`);
196
+ return { summary: 'unexpectedly allowed' };
197
+ }),
198
+ ]);
199
+
200
+ return (
201
+ <>
202
+ <Card title="HawkSearch dashboard API (hawksearch:read)">
203
+ <label>
204
+ Path <input value={path} onChange={(e) => setPath(e.target.value)} />
205
+ </label>{' '}
206
+ <button onClick={run}>GET</button>
207
+ <Results outcomes={outcomes} />
208
+ </Card>
209
+ <DashboardWriteTest app={app} />
210
+ </>
211
+ );
212
+ }
213
+
214
+ /** HawkSearch's Synonym model (HawkSearch.Contracts.Api.Synonym). */
215
+ interface Synonym {
216
+ Id: number;
217
+ SearchTerm: string;
218
+ SynonymGroup: string;
219
+ MultiWay: boolean;
220
+ Comments?: string | null;
221
+ }
222
+
223
+ /** Expects the call to fail with `status` (and `code`, when given); anything else, including success, is a failure. */
224
+ async function expectError(label: string, status: number, code: string | undefined, call: () => Promise<unknown>): Promise<Outcome> {
225
+ return measure(`${label} (expect ${status}${code ? ` ${code}` : ''})`, async () => {
226
+ try {
227
+ await call();
228
+ } catch (e) {
229
+ if (e instanceof HawkApiError && e.status === status && (!code || e.code === code)) return { summary: `${describe(e)} (as expected)` };
230
+ throw e;
231
+ }
232
+ throw new Error('unexpectedly allowed');
233
+ });
234
+ }
235
+
236
+ /**
237
+ * hawksearch:write round trip on a throwaway synonym: create, read back, delete, check it's gone. The synonym is
238
+ * clearly marked as a test and deleted in the same run (also when the read-back fails).
239
+ */
240
+ function DashboardWriteTest({ app }: { app: HawkApp }) {
241
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
242
+ const [busy, setBusy] = useState(false);
243
+
244
+ const go = async (runs: () => Promise<Outcome[]>) => {
245
+ setBusy(true);
246
+ setOutcomes(await runs());
247
+ setBusy(false);
248
+ };
249
+
250
+ const roundTrip = async (): Promise<Outcome[]> => {
251
+ const marker = `hawk-apps-showcase-${Date.now()}`;
252
+ const body = {
253
+ SearchTerm: marker,
254
+ SynonymGroup: `${marker}-a,${marker}-b`,
255
+ MultiWay: false,
256
+ Comments: 'Created by the Hawk Apps showcase app (write test); deleted right away. Safe to delete.',
257
+ };
258
+ const results: Outcome[] = [];
259
+ let id: number | undefined;
260
+
261
+ results.push(
262
+ await measure("dashboard.post('Synonym')", async () => {
263
+ const created = await app.dashboard.post<Synonym>('Synonym', body);
264
+ id = created?.Id;
265
+ if (!id) throw new Error(`no Id in the response: ${JSON.stringify(created)}`);
266
+ return { summary: `created synonym ${id} (${marker})`, detail: created };
267
+ }),
268
+ );
269
+ if (!id) return results;
270
+
271
+ results.push(
272
+ await measure(`dashboard.get('Synonym/${id}')`, async () => {
273
+ const synonym = await app.dashboard.get<Synonym>(`Synonym/${id}`);
274
+ if (synonym?.SearchTerm !== marker) throw new Error(`read back ${JSON.stringify(synonym?.SearchTerm)}, expected ${marker}`);
275
+ return { summary: `read back: ${synonym.SearchTerm} → ${synonym.SynonymGroup}`, detail: synonym };
276
+ }),
277
+ );
278
+ results.push(
279
+ await measure(`dashboard.delete('Synonym/${id}')`, async () => {
280
+ const removed = await app.dashboard.delete<Synonym | undefined>(`Synonym/${id}`);
281
+ return { summary: 'deleted', detail: removed };
282
+ }),
283
+ );
284
+ // HawkSearch answers 404 for a deleted synonym (its own error body, no platform code).
285
+ results.push(await expectError(`dashboard.get('Synonym/${id}') after delete`, 404, undefined, () => app.dashboard.get(`Synonym/${id}`)));
286
+ return results;
287
+ };
288
+
289
+ // Field isn't a writable resource. The body is empty, so even a wrongly allowed call couldn't create a field.
290
+ const notAllowed = () => expectError("dashboard.post('Field')", 403, 'path_not_allowed', () => app.dashboard.post('Field', {}));
291
+
292
+ return (
293
+ <Card title="Write test (hawksearch:write)">
294
+ <p className="muted">
295
+ Creates a synonym marked <code>hawk-apps-showcase-…</code> in the current client, reads it back and deletes it. Writes are
296
+ audit-logged and never retried; 120 per minute per app and client.
297
+ </p>
298
+ <div className="buttons">
299
+ <button disabled={busy} onClick={() => go(roundTrip)}>
300
+ Create, read, delete synonym
301
+ </button>
302
+ <button disabled={busy} onClick={() => go(async () => [await notAllowed()])}>
303
+ Error case
304
+ </button>
305
+ </div>
306
+ <Results outcomes={outcomes} />
307
+ </Card>
308
+ );
309
+ }
310
+
311
+ /** The part of a search result the tab shows. */
312
+ function searchSummary(result: SearchResponse): { summary: string; detail: unknown } {
313
+ const results = result.Results ?? [];
314
+ const total = result.Pagination?.NofResults ?? results.length;
315
+ const redirect = result.Redirect?.Location ? ` · redirect ${result.Redirect.Location}` : '';
316
+ return { summary: `${total} results${redirect}`, detail: { total, firstDocIds: results.slice(0, 10).map((r) => r.DocId), response: result } };
317
+ }
318
+
319
+ /** Runs `work` over `items`, at most `limit` at a time (the platform allows 600 searches per minute per app and client). */
320
+ async function pool<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
321
+ const results: R[] = new Array(items.length);
322
+ let next = 0;
323
+ await Promise.all(
324
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
325
+ while (next < items.length) {
326
+ const i = next++;
327
+ results[i] = await work(items[i]);
328
+ }
329
+ }),
330
+ );
331
+ return results;
332
+ }
333
+
334
+ function SearchTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
335
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
336
+ const [busy, setBusy] = useState(false);
337
+ const [keyword, setKeyword] = useState('boots');
338
+ const [previewDate, setPreviewDate] = useState('');
339
+ const [keywords, setKeywords] = useState('boots\njacket\nzzz-no-such-product');
340
+
341
+ const go = async (runs: () => Promise<Outcome[]>) => {
342
+ setBusy(true);
343
+ setOutcomes(await runs());
344
+ setBusy(false);
345
+ };
346
+
347
+ const search = (label: string, request: SearchRequest) =>
348
+ measure(label, async () => searchSummary(await app.hawksearch.search(request)));
349
+
350
+ const previewRequest = (): SearchRequest => ({
351
+ Keyword: keyword,
352
+ IsInPreview: true,
353
+ ...(previewDate ? { SmartBar: { PreviewDate: `${previewDate}T00:00:00` } } : {}),
354
+ });
355
+
356
+ const autocomplete = () =>
357
+ measure(`hawksearch.autocomplete({ Keyword: ${JSON.stringify(keyword)} })`, async () => {
358
+ const result = await app.hawksearch.autocomplete<Record<string, unknown>>({ Keyword: keyword, ProductCount: 5 });
359
+ const counts = Object.entries(result)
360
+ .filter(([, v]) => Array.isArray(v))
361
+ .map(([k, v]) => `${k} ${(v as unknown[]).length}`);
362
+ return { summary: counts.join(' · ') || 'no lists', detail: result };
363
+ });
364
+
365
+ // Every keyword, 5 at a time; zero-result keywords fail so they stand out.
366
+ const zeroResults = async (): Promise<Outcome[]> => {
367
+ const list = [...new Set(keywords.split('\n').map((k) => k.trim()).filter(Boolean))].slice(0, 100);
368
+ return pool(list, 5, (k) =>
369
+ measure(`"${k}"`, async () => {
370
+ const { summary, detail } = searchSummary(await app.hawksearch.search({ Keyword: k, MaxPerPage: 1 }));
371
+ if (summary.startsWith('0 results')) throw new Error('0 results');
372
+ return { summary, detail };
373
+ }),
374
+ );
375
+ };
376
+
377
+ // The analytics "queries" report (reports:read): the keyword column is the first one named like keyword/query/term.
378
+ const loadTopQueries = async (): Promise<Outcome[]> => [
379
+ await measure("analytics.report('queries') → keywords", async () => {
380
+ const report = await app.analytics.report<Record<string, unknown>>('queries', { limit: 100 });
381
+ const column = Object.keys(report.data[0] ?? {}).find((c) => /keyword|query|term/i.test(c));
382
+ if (!column) throw new Error(`no keyword column in ${JSON.stringify(Object.keys(report.data[0] ?? {}))}`);
383
+ const found = report.data.map((row) => String(row[column] ?? '').trim()).filter(Boolean);
384
+ setKeywords(found.join('\n'));
385
+ return { summary: `${found.length} keywords from column "${column}"` };
386
+ }),
387
+ ];
388
+
389
+ return (
390
+ <>
391
+ <Card title="Search (hawksearch:search)">
392
+ <p className="muted">
393
+ HawkSearch v2 search and autocomplete for engine{' '}
394
+ {context.engine ? `${context.engine.id}${context.engine.name ? ` (${context.engine.name})` : ''}` : '(none: expect 400 engine_required)'}. The platform
395
+ sets the ClientGuid; preview uses the engine's preview search API.
396
+ </p>
397
+ <div className="buttons">
398
+ <label>
399
+ Keyword <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
400
+ </label>
401
+ <label>
402
+ Preview date <input type="date" value={previewDate} onChange={(e) => setPreviewDate(e.target.value)} />
403
+ </label>
404
+ </div>
405
+ <div className="buttons">
406
+ <button disabled={busy} onClick={() => go(async () => [await search(`hawksearch.search({ Keyword: ${JSON.stringify(keyword)} })`, { Keyword: keyword })])}>
407
+ Search
408
+ </button>
409
+ <button
410
+ disabled={busy}
411
+ onClick={() =>
412
+ go(() => Promise.all([search('live', { Keyword: keyword }), search(`preview${previewDate ? ` as of ${previewDate}` : ''}`, previewRequest())]))
413
+ }
414
+ >
415
+ Live vs preview
416
+ </button>
417
+ <button disabled={busy} onClick={() => go(async () => [await autocomplete()])}>
418
+ Autocomplete
419
+ </button>
420
+ <button
421
+ disabled={busy}
422
+ onClick={() =>
423
+ go(async () => [
424
+ await search('ClientGuid in the request is replaced (expect normal results)', { Keyword: keyword, ClientGuid: 'not-this-engine' }),
425
+ await measure('hawksearch.search(null) (expect TypeError, no call)', async () => {
426
+ try {
427
+ await app.hawksearch.search(null as never);
428
+ } catch (e) {
429
+ if (e instanceof TypeError) return { summary: `${e.message} (as expected)` };
430
+ throw e;
431
+ }
432
+ throw new Error('unexpectedly allowed');
433
+ }),
434
+ ])
435
+ }
436
+ >
437
+ Error cases
438
+ </button>
439
+ </div>
440
+ <Results outcomes={outcomes} />
441
+ </Card>
442
+ <Card title="Zero-result check">
443
+ <p className="muted">One keyword per line (up to 100), 5 searches at a time. Keywords with 0 results show as FAIL.</p>
444
+ <textarea rows={6} value={keywords} onChange={(e) => setKeywords(e.target.value)} />
445
+ <div className="buttons">
446
+ <button disabled={busy} onClick={() => go(loadTopQueries)}>
447
+ Load top queries (analytics)
448
+ </button>
449
+ <button disabled={busy} onClick={() => go(zeroResults)}>
450
+ Check keywords
451
+ </button>
452
+ </div>
453
+ </Card>
454
+ </>
455
+ );
456
+ }
457
+
458
+ function QueriesTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
459
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
460
+ const [busy, setBusy] = useState(false);
461
+ const { from, to } = range(context);
462
+
463
+ const query = (label: string, name: string, params: Record<string, unknown>) =>
464
+ measure(label, async () => {
465
+ const result = await app.data.query(name, params as never);
466
+ return {
467
+ summary: `${result.rowCount} rows · ${result.bytesScanned} B scanned${result.cached ? ' · cached' : ''}${shown(result.rows)}`,
468
+ detail: preview(result.rows),
469
+ };
470
+ });
471
+
472
+ const go = async (runs: () => Promise<Outcome[]>) => {
473
+ setBusy(true);
474
+ setOutcomes(await runs());
475
+ setBusy(false);
476
+ };
477
+
478
+ return (
479
+ <Card title="Queries (data:read)">
480
+ <p className="muted">
481
+ Range {from} – {to}. Queries ship in <code>queries/</code> with each version.
482
+ </p>
483
+ <div className="buttons">
484
+ <button disabled={busy} onClick={() => go(async () => [await query('searches-by-day (allowed table)', 'searches-by-day', { from, to })])}>
485
+ Allowed table
486
+ </button>
487
+ <button disabled={busy} onClick={() => go(async () => [await query('searches-by-day again (expect cached)', 'searches-by-day', { from, to })])}>
488
+ Repeat (cached)
489
+ </button>
490
+ <button
491
+ disabled={busy}
492
+ onClick={() => go(async () => [await query('assistant-prompts-by-day (sensitive: needs the app opt-in, else TABLE_NOT_FOUND)', 'assistant-prompts-by-day', { from, to })])}
493
+ >
494
+ Sensitive table
495
+ </button>
496
+ <button
497
+ disabled={busy}
498
+ onClick={() =>
499
+ go(() =>
500
+ // Different ranges so each is a new execution: more than 2 at once per app and client
501
+ // hits the limit (429), which the bridge retries until a slot frees up.
502
+ Promise.all(
503
+ [1, 2, 3, 4, 5].map((n) => {
504
+ const end = new Date(new Date(`${to}T00:00:00Z`).getTime() - n * 86_400_000).toISOString().slice(0, 10);
505
+ return query(`burst #${n} (to ${end})`, 'searches-by-day', { from, to: end });
506
+ }),
507
+ ),
508
+ )
509
+ }
510
+ >
511
+ Burst ×5 (concurrency)
512
+ </button>
513
+ <button
514
+ disabled={busy}
515
+ onClick={() =>
516
+ go(async () => [
517
+ await query('invalid params (expect 400 invalid_params)', 'searches-by-day', { from: 'not-a-date', to }),
518
+ await query('unknown query (expect 404 query_not_found)', 'does-not-exist', {}),
519
+ ])
520
+ }
521
+ >
522
+ Error cases
523
+ </button>
524
+ </div>
525
+ <Results outcomes={outcomes} />
526
+ </Card>
527
+ );
528
+ }
529
+
530
+ interface ShowcaseStep {
531
+ name: string;
532
+ ok: boolean;
533
+ ms: number;
534
+ summary: string;
535
+ detail?: unknown;
536
+ }
537
+
538
+ function FunctionsTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
539
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
540
+ const [busy, setBusy] = useState(false);
541
+ const [text, setText] = useState('hello');
542
+
543
+ const call = (label: string, name: string, input: unknown) =>
544
+ measure(label, async () => {
545
+ const result = await app.functions.call<Record<string, unknown>>(name, input);
546
+ const facts = ['client', 'user', 'version', 'hasSecret', 'searches'].map((key) => `${key} ${JSON.stringify(result?.[key])}`);
547
+ return { summary: facts.join(' · '), detail: result };
548
+ });
549
+
550
+ // The showcase reports its own steps: show each as a result row, after a row for the call itself.
551
+ const showcase = async (): Promise<Outcome[]> => {
552
+ const started = performance.now();
553
+ try {
554
+ const result = await app.functions.call<{ steps: ShowcaseStep[]; totalMs: number; runtime: Record<string, unknown> }>(
555
+ 'showcase',
556
+ range(context),
557
+ { timeoutMs: 40_000 },
558
+ );
559
+ const passed = result.steps.filter((s) => s.ok).length;
560
+ return [
561
+ {
562
+ label: 'showcase (round trip)',
563
+ ok: passed === result.steps.length,
564
+ ms: Math.round(performance.now() - started),
565
+ summary: `${passed}/${result.steps.length} steps ok · ${result.totalMs} ms in the function`,
566
+ detail: result.runtime,
567
+ },
568
+ ...result.steps.map((s) => ({ label: s.name, ok: s.ok, ms: s.ms, summary: s.summary, detail: s.detail })),
569
+ ];
570
+ } catch (e) {
571
+ return [{ label: 'showcase', ok: false, ms: Math.round(performance.now() - started), summary: describe(e) }];
572
+ }
573
+ };
574
+
575
+ const go = async (runs: () => Promise<Outcome[]>) => {
576
+ setBusy(true);
577
+ setOutcomes(await runs());
578
+ setBusy(false);
579
+ };
580
+
581
+ return (
582
+ <Card title="Functions (functions:invoke)">
583
+ <p className="muted">
584
+ <code>functions/showcase.ts</code> runs every function capability (input validation with an npm package, context, secrets, the
585
+ analytics and dashboard APIs, an app query, Athena directly with client-scoped credentials, outbound HTTP, and its own logic)
586
+ and reports each step; <code>functions/echo.ts</code> is the minimal one. Both ship with each version. Needs functions enabled for the app (else 403 functions_not_enabled) and the
587
+ <code> TEST_SECRET</code> secret (<code>npx hawk-app secrets set TEST_SECRET</code>; else 500 secret_missing).
588
+ </p>
589
+ <label>
590
+ Input <input value={text} onChange={(e) => setText(e.target.value)} />
591
+ </label>{' '}
592
+ <div className="buttons">
593
+ <button disabled={busy} onClick={() => go(showcase)}>
594
+ Run showcase
595
+ </button>
596
+ <button disabled={busy} onClick={() => go(async () => [await call('echo', 'echo', { text, at: new Date().toISOString() })])}>
597
+ Call echo
598
+ </button>
599
+ <button disabled={busy} onClick={() => go(() => Promise.all([1, 2, 3, 4, 5, 6, 7].map((n) => call(`echo burst #${n}`, 'echo', { n }))))}>
600
+ Burst ×7 (concurrency)
601
+ </button>
602
+ <button disabled={busy} onClick={() => go(async () => [await call('missing function (expect 404 function_not_found)', 'does-not-exist', {})])}>
603
+ Error case
604
+ </button>
605
+ </div>
606
+ <Results outcomes={outcomes} />
607
+ </Card>
608
+ );
609
+ }
610
+
611
+ /** A job as one result row. */
612
+ function jobOutcome(label: string, job: FunctionJob<Record<string, unknown>>, ms: number): Outcome {
613
+ const progress = job.progress ? ` · ${job.progress.percent ?? '?'}% ${job.progress.message ?? ''}` : '';
614
+ return {
615
+ label,
616
+ ok: job.status === 'succeeded',
617
+ ms,
618
+ summary: `${job.status}${progress}${job.error ? ` · ${job.error.type}${job.error.message ? `: ${job.error.message}` : ''}` : ''}`,
619
+ detail: job,
620
+ };
621
+ }
622
+
623
+ function JobsTab({ app }: { app: HawkApp }) {
624
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
625
+ const [live, setLive] = useState<FunctionJob | null>(null);
626
+ const [schedules, setSchedules] = useState<AppSchedule[] | null>(null);
627
+ const [busy, setBusy] = useState(false);
628
+ const [steps, setSteps] = useState(10);
629
+
630
+ const go = async (runs: () => Promise<Outcome[]>) => {
631
+ setBusy(true);
632
+ setOutcomes(await runs());
633
+ setBusy(false);
634
+ };
635
+
636
+ // Start, then wait while showing progress live.
637
+ const runJob = async (input: Record<string, unknown>, label: string): Promise<Outcome[]> => {
638
+ const started = performance.now();
639
+ try {
640
+ const { jobId } = await app.functions.start('long-task', input);
641
+ const job = await app.functions.wait<Record<string, unknown>>(jobId, { intervalMs: 2000, onProgress: (j) => setLive(j) });
642
+ setLive(null);
643
+ return [jobOutcome(label, job, Math.round(performance.now() - started))];
644
+ } catch (e) {
645
+ setLive(null);
646
+ if (e instanceof FunctionJobError) {
647
+ return [jobOutcome(`${label} (failed as a job)`, e.job as FunctionJob<Record<string, unknown>>, Math.round(performance.now() - started))];
648
+ }
649
+ return [{ label, ok: false, ms: Math.round(performance.now() - started), summary: describe(e) }];
650
+ }
651
+ };
652
+
653
+ const listSchedules = () =>
654
+ measure('schedules.list()', async () => {
655
+ const list = await app.schedules.list();
656
+ setSchedules(list);
657
+ return { summary: `${list.length} schedules · ${list.filter((s) => s.enabled).length} on for this client`, detail: list };
658
+ });
659
+
660
+ const toggle = (schedule: AppSchedule) =>
661
+ measure(`schedules.${schedule.enabled ? 'disable' : 'enable'}('${schedule.name}')`, async () => {
662
+ const updated = schedule.enabled ? await app.schedules.disable(schedule.name) : await app.schedules.enable(schedule.name);
663
+ return { summary: updated.enabled ? 'on' : 'off', detail: updated };
664
+ });
665
+
666
+ return (
667
+ <>
668
+ <Card title="Background jobs (functions:invoke, &quot;job&quot;: true)">
669
+ <p className="muted">
670
+ <code>functions/long-task.ts</code> counts through steps (3 s each), reporting progress, and returns what it knows about the run.
671
+ Jobs run up to 15 minutes; 3 at once per app and client.
672
+ </p>
673
+ <div className="buttons">
674
+ <label>
675
+ Steps <input type="number" min={1} max={60} value={steps} onChange={(e) => setSteps(Number(e.target.value))} />
676
+ </label>
677
+ <button disabled={busy} onClick={() => go(() => runJob({ steps }, `long-task (${steps} steps)`))}>
678
+ Start and wait
679
+ </button>
680
+ <button disabled={busy} onClick={() => go(() => runJob({ steps: 2, fail: true }, 'long-task failing (expect failed)'))}>
681
+ Failing job
682
+ </button>
683
+ <button
684
+ disabled={busy}
685
+ onClick={() =>
686
+ go(async () => [
687
+ await expectError("functions.call('long-task') (expect 409 function_is_job)", 409, 'function_is_job', () => app.functions.call('long-task', {})),
688
+ await expectError("functions.start('echo') (expect 409 function_not_job)", 409, 'function_not_job', () => app.functions.start('echo', {})),
689
+ ])
690
+ }
691
+ >
692
+ Error cases
693
+ </button>
694
+ </div>
695
+ {live && (
696
+ <p>
697
+ <progress max={100} value={live.progress?.percent ?? 0} /> {live.status} · {live.progress?.percent ?? 0}% {live.progress?.message ?? ''}
698
+ </p>
699
+ )}
700
+ <Results outcomes={outcomes} />
701
+ </Card>
702
+ <Card title="Schedules">
703
+ <p className="muted">
704
+ <code>daily-check</code> runs long-task at 13:00 America/Chicago for each client where it is on (as <code>schedule:daily-check</code>, on this
705
+ site, with this client&apos;s HawkSearch connection). Approved schedules only.
706
+ </p>
707
+ <div className="buttons">
708
+ <button disabled={busy} onClick={() => go(async () => [await listSchedules()])}>
709
+ List
710
+ </button>
711
+ </div>
712
+ {schedules && (
713
+ <table className="params">
714
+ <thead>
715
+ <tr>
716
+ <th>Name</th>
717
+ <th>Runs</th>
718
+ <th>For this client</th>
719
+ <th>Last run</th>
720
+ <th />
721
+ </tr>
722
+ </thead>
723
+ <tbody>
724
+ {schedules.map((s) => (
725
+ <tr key={s.name}>
726
+ <td>{s.name}</td>
727
+ <td>
728
+ {s.function} · <code>{s.cron}</code> ({s.timezone})
729
+ </td>
730
+ <td>{s.enabled ? `on (by ${s.enabledBy?.email ?? s.enabledBy?.id ?? '?'})` : 'off'}</td>
731
+ <td>{s.lastRun ? `${new Date(s.lastRun.at).toLocaleString()} · ${s.lastRun.status}${s.lastRun.reason ? ` (${s.lastRun.reason})` : ''}` : '—'}</td>
732
+ <td>
733
+ <button disabled={busy} onClick={() => go(async () => [await toggle(s), await listSchedules()])}>
734
+ {s.enabled ? 'Turn off' : 'Turn on'}
735
+ </button>
736
+ </td>
737
+ </tr>
738
+ ))}
739
+ </tbody>
740
+ </table>
741
+ )}
742
+ </Card>
743
+ </>
744
+ );
745
+ }
746
+
747
+ /** The names declared in hawk-app.json "systemParameters" (only these can be read or set). */
748
+ const DECLARED_SYSTEM_PARAMETERS = ['Timezone', 'SearchPreviewURL'];
749
+
750
+ function SystemParametersTab({ app, context }: { app: HawkApp; context: HawkAppContext }) {
751
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
752
+ const [parameters, setParameters] = useState<SystemParameter[] | null>(null);
753
+ const [busy, setBusy] = useState(false);
754
+ const [name, setName] = useState(DECLARED_SYSTEM_PARAMETERS[0]);
755
+ const [value, setValue] = useState('');
756
+
757
+ const go = async (runs: () => Promise<Outcome[]>) => {
758
+ setBusy(true);
759
+ setOutcomes(await runs());
760
+ setBusy(false);
761
+ };
762
+
763
+ const list = () =>
764
+ measure('hawksearch.systemParameters.list()', async () => {
765
+ const result = await app.hawksearch.systemParameters.list();
766
+ setParameters(result.parameters);
767
+ return { summary: `engine ${result.engineId} · ${result.parameters.length} parameters`, detail: result };
768
+ });
769
+
770
+ const set = () =>
771
+ measure(`hawksearch.systemParameters.set('${name}', ${JSON.stringify(value)})`, async () => {
772
+ const result = await app.hawksearch.systemParameters.set(name, value === '' ? null : value);
773
+ return {
774
+ summary: `${JSON.stringify(result.previous)} → ${JSON.stringify(result.value)} · applies within ${result.appliesWithinSeconds} s`,
775
+ detail: result,
776
+ };
777
+ });
778
+
779
+ const undeclared = () =>
780
+ measure("hawksearch.systemParameters.set('WebsiteUrl') (undeclared: expect 404 parameter_not_found)", async () => {
781
+ await app.hawksearch.systemParameters.set('WebsiteUrl', 'https://example.com');
782
+ return { summary: 'unexpectedly allowed' };
783
+ });
784
+
785
+ return (
786
+ <Card title="HawkSearch system parameters (hawksearch:sysparams:write)">
787
+ <p className="muted">
788
+ Engine {context.engine ? `${context.engine.id}${context.engine.name ? ` (${context.engine.name})` : ''}` : '(none: expect 400 engine_required)'}. Declared in{' '}
789
+ <code>hawk-app.json</code>: {DECLARED_SYSTEM_PARAMETERS.join(', ')}. Changes apply in HawkSearch within about 3 minutes.
790
+ </p>
791
+ <div className="buttons">
792
+ <button disabled={busy} onClick={() => go(async () => [await list()])}>
793
+ List
794
+ </button>
795
+ </div>
796
+ {parameters && (
797
+ <table className="params">
798
+ <thead>
799
+ <tr>
800
+ <th>Name</th>
801
+ <th>Label</th>
802
+ <th>Type</th>
803
+ <th>Value</th>
804
+ <th>Read-only</th>
805
+ </tr>
806
+ </thead>
807
+ <tbody>
808
+ {parameters.map((p) => (
809
+ <tr key={p.name}>
810
+ <td>{p.name}</td>
811
+ <td>{p.label ?? ''}</td>
812
+ <td>{p.type}</td>
813
+ <td>{p.encrypted ? '(encrypted)' : p.value ?? <span className="muted">(not set)</span>}</td>
814
+ <td>{p.readOnly ? 'yes' : 'no'}</td>
815
+ </tr>
816
+ ))}
817
+ </tbody>
818
+ </table>
819
+ )}
820
+ <div className="buttons">
821
+ <select value={name} onChange={(e) => setName(e.target.value)}>
822
+ {DECLARED_SYSTEM_PARAMETERS.map((n) => (
823
+ <option key={n}>{n}</option>
824
+ ))}
825
+ </select>
826
+ <input placeholder="value (empty clears it)" value={value} onChange={(e) => setValue(e.target.value)} />
827
+ <button disabled={busy} onClick={() => go(async () => [await set(), await list()])}>
828
+ Set
829
+ </button>
830
+ <button disabled={busy} onClick={() => go(async () => [await undeclared()])}>
831
+ Error case
832
+ </button>
833
+ </div>
834
+ <Results outcomes={outcomes} />
835
+ </Card>
836
+ );
837
+ }
838
+
839
+ function AiTab({ app }: { app: HawkApp }) {
840
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
841
+ const [busy, setBusy] = useState(false);
842
+ const [model, setModel] = useState<AiModel>('fast');
843
+ const [system, setSystem] = useState('');
844
+ const [prompt, setPrompt] = useState('In two sentences, what is site search merchandising?');
845
+ const [output, setOutput] = useState('');
846
+ const [info, setInfo] = useState<AiInfo | null>(null);
847
+ // Stops the running call (the stream's abort(), or generate's AbortSignal).
848
+ const stop = useRef<(() => void) | null>(null);
849
+
850
+ const request = () => ({ model, prompt, ...(system.trim() ? { system } : {}) });
851
+ const record = (outcome: Outcome) => setOutcomes((previous) => [outcome, ...previous].slice(0, 10));
852
+ const summarize = (result: AiResult) => `${result.model} · ${result.stopReason} · ${result.usage.inputTokens} in / ${result.usage.outputTokens} out · id ${result.id}`;
853
+
854
+ const stream = async () => {
855
+ setBusy(true);
856
+ setOutput('');
857
+ const s = app.ai.stream(request());
858
+ stop.current = () => s.abort();
859
+ record(
860
+ await measure(`ai.stream({ model: '${model}' })`, async () => {
861
+ let chunks = 0;
862
+ for await (const text of s) {
863
+ chunks++;
864
+ setOutput((previous) => previous + text);
865
+ }
866
+ const result = await s.result;
867
+ return { summary: `${chunks} chunks · ${summarize(result)}`, detail: result };
868
+ }),
869
+ );
870
+ stop.current = null;
871
+ setBusy(false);
872
+ };
873
+
874
+ const generate = async () => {
875
+ setBusy(true);
876
+ setOutput('');
877
+ const controller = new AbortController();
878
+ stop.current = () => controller.abort();
879
+ record(
880
+ await measure(`ai.generate({ model: '${model}' })`, async () => {
881
+ const result = await app.ai.generate(request(), { signal: controller.signal });
882
+ setOutput(result.text);
883
+ return { summary: summarize(result), detail: result };
884
+ }),
885
+ );
886
+ stop.current = null;
887
+ setBusy(false);
888
+ };
889
+
890
+ const loadInfo = async () =>
891
+ record(
892
+ await measure('ai.info()', async () => {
893
+ const result = await app.ai.info();
894
+ setInfo(result);
895
+ return { summary: result.tiers.map((t) => `${t.name} ${t.usedTokens}/${t.dailyTokens}`).join(' · '), detail: result };
896
+ }),
897
+ );
898
+
899
+ return (
900
+ <Card title="AI (ai:invoke)">
901
+ <p className="muted">
902
+ Claude through the platform: <code>fast</code> (Haiku) or <code>smart</code> (Sonnet). Every call costs tokens from the tier's daily budget
903
+ for this client; nothing is retried.
904
+ </p>
905
+ <div className="buttons">
906
+ <select value={model} onChange={(e) => setModel(e.target.value as AiModel)}>
907
+ <option value="fast">fast</option>
908
+ <option value="smart">smart</option>
909
+ </select>
910
+ <button onClick={loadInfo}>Info</button>
911
+ </div>
912
+ {info && (
913
+ <table className="params">
914
+ <thead>
915
+ <tr>
916
+ <th>Tier</th>
917
+ <th>Used today</th>
918
+ <th>Daily tokens</th>
919
+ <th>Max output tokens</th>
920
+ </tr>
921
+ </thead>
922
+ <tbody>
923
+ {info.tiers.map((t) => (
924
+ <tr key={t.name}>
925
+ <td>{t.name}</td>
926
+ <td>{t.usedTokens.toLocaleString()}</td>
927
+ <td>{t.dailyTokens.toLocaleString()}</td>
928
+ <td>{t.maxOutputTokens.toLocaleString()}</td>
929
+ </tr>
930
+ ))}
931
+ </tbody>
932
+ </table>
933
+ )}
934
+ {info && (
935
+ <p className="muted">
936
+ Max input {info.maxInputBytes.toLocaleString()} bytes · {info.requestsPerMinute} requests per minute.
937
+ </p>
938
+ )}
939
+ <textarea rows={2} placeholder="System prompt (optional)" value={system} onChange={(e) => setSystem(e.target.value)} />
940
+ <textarea rows={4} placeholder="Prompt" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
941
+ <div className="buttons">
942
+ <button disabled={busy || !prompt.trim()} onClick={stream}>
943
+ Stream
944
+ </button>
945
+ <button disabled={busy || !prompt.trim()} onClick={generate}>
946
+ Generate
947
+ </button>
948
+ <button disabled={!busy} onClick={() => stop.current?.()}>
949
+ Stop
950
+ </button>
951
+ </div>
952
+ <textarea rows={10} readOnly value={output} placeholder="The answer appears here as it arrives." />
953
+ <Results outcomes={outcomes} />
954
+ </Card>
955
+ );
956
+ }
957
+
958
+ /** Index rebuilds (hawksearch:index): the latest run, and a full rebuild followed to the end. */
959
+ function IndexTab({ app }: { app: HawkApp }) {
960
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
961
+ const [progress, setProgress] = useState('');
962
+ const add = (o: Outcome) => setOutcomes((current) => [...current, o]);
963
+ const describeRun = (r: IndexRun) => `${r.status}${r.done ? ' (done)' : ''} · ${r.count ?? 0} items${r.durationSeconds !== null ? ` · ${r.durationSeconds} s` : ''}`;
964
+
965
+ const status = async () => {
966
+ setOutcomes([]);
967
+ add(await measure('index.status()', async () => {
968
+ const run = await app.hawksearch.index.status();
969
+ return { summary: describeRun(run), detail: run };
970
+ }));
971
+ };
972
+
973
+ // The Indexing API's reads (engines with their own indexes; others answer 503 indexing_api_not_available).
974
+ const indexes = async () => {
975
+ setOutcomes([]);
976
+ add(await measure('index.indexes()', async () => {
977
+ const names = await app.hawksearch.index.indexes();
978
+ return { summary: `${names.length} indexes`, detail: names };
979
+ }));
980
+ add(await measure('index.current()', async () => ({ summary: (await app.hawksearch.index.current()) ?? 'none' })));
981
+ };
982
+
983
+ const rebuild = async () => {
984
+ setOutcomes([]);
985
+ setProgress('');
986
+ let id: number | null = null;
987
+ add(await measure('index.rebuild()', async () => {
988
+ const started = await app.hawksearch.index.rebuild();
989
+ id = started.notificationId;
990
+ return { summary: `#${started.notificationId} ${describeRun(started)}`, detail: started };
991
+ }));
992
+ const runId = id as number | null; // set inside the callback above
993
+ if (runId !== null) {
994
+ add(await measure(`index.wait(${runId})`, async () => {
995
+ const final = await app.hawksearch.index.wait(runId, { intervalMs: 10_000, onProgress: (r) => setProgress(describeRun(r)) });
996
+ return { summary: describeRun(final), detail: final };
997
+ }));
998
+ }
999
+ };
1000
+
1001
+ return (
1002
+ <>
1003
+ <Card title="Index rebuilds (hawksearch:index)">
1004
+ <p className="muted">
1005
+ A rebuild reloads this engine&apos;s live search from its feed. It needs the engine&apos;s EnableRebuildIndex on, and one runs at a time.
1006
+ </p>
1007
+ <button onClick={indexes}>Indexes</button> <button onClick={status}>Latest rebuild</button>{' '}
1008
+ <button onClick={rebuild}>Rebuild now and wait</button>
1009
+ {progress && <p className="muted">Now: {progress}</p>}
1010
+ <Results outcomes={outcomes} />
1011
+ </Card>
1012
+ <IndexItemsCard app={app} />
1013
+ </>
1014
+ );
1015
+ }
1016
+
1017
+ /**
1018
+ * Item writes (hawksearch:index:items): one field of one item, updated and read back through search, then put back to
1019
+ * the value it had. The Indexing API applies writes at once: on the current index, that's the engine's live search.
1020
+ */
1021
+ function IndexItemsCard({ app }: { app: HawkApp }) {
1022
+ const [indexNames, setIndexNames] = useState<string[]>([]);
1023
+ const [indexName, setIndexName] = useState('');
1024
+ const [primaryKey, setPrimaryKey] = useState('id');
1025
+ const [itemId, setItemId] = useState('');
1026
+ const [field, setField] = useState('');
1027
+ const [value, setValue] = useState('');
1028
+ const [understood, setUnderstood] = useState(false);
1029
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1030
+ const add = (o: Outcome) => setOutcomes((current) => [...current, o]);
1031
+ const ready = understood && indexName && primaryKey.trim() && itemId.trim() && field.trim();
1032
+
1033
+ const load = async () => {
1034
+ setOutcomes([]);
1035
+ add(await measure('index.indexes() + current()', async () => {
1036
+ const [names, current] = await Promise.all([app.hawksearch.index.indexes(), app.hawksearch.index.current()]);
1037
+ setIndexNames(names);
1038
+ setIndexName(current ?? names[0] ?? '');
1039
+ return { summary: `${names.length} indexes, current: ${current ?? 'none'}`, detail: { names, current } };
1040
+ }));
1041
+ };
1042
+
1043
+ // Each field is an array of values; the item is named by the engine's primary key (set in HawkSearch's fields).
1044
+ const item = (fields: Record<string, unknown[]>) => ({ [primaryKey.trim()]: [itemId.trim()], ...fields });
1045
+
1046
+ // The field's values before the first change, read through search (the live index), to put back afterwards.
1047
+ const [previous, setPrevious] = useState<unknown[] | null>(null);
1048
+
1049
+ /** The item's field as search sees it now (only the current index is searched). */
1050
+ const read = (label: string) =>
1051
+ measure(label, async () => {
1052
+ const found = await app.hawksearch.search({ Keyword: itemId.trim(), MaxPerPage: 1 });
1053
+ const values = found.Results?.[0]?.Document?.[field.trim()];
1054
+ return { summary: `${found.Pagination?.NofResults ?? 0} results · ${field}: ${JSON.stringify(values ?? null)}`, detail: { values, values_raw: found.Results?.[0] } };
1055
+ });
1056
+
1057
+ const write = (values: unknown[]) =>
1058
+ measure(`index.items.update(${itemId}: ${field} = ${JSON.stringify(values)})`, async () => {
1059
+ const result: IndexItemsResult = await app.hawksearch.index.items.update([item({ [field.trim()]: values })], { indexName });
1060
+ return { summary: `${result.Status ?? '?'} · ${result.Summary?.Succeeded ?? 0} of ${result.Summary?.Total ?? 0} updated`, detail: result };
1061
+ });
1062
+
1063
+ const update = async () => {
1064
+ setOutcomes([]);
1065
+ if (previous === null) {
1066
+ const before = await app.hawksearch.search({ Keyword: itemId.trim(), MaxPerPage: 1 }).catch(() => null);
1067
+ const values = before?.Results?.[0]?.Document?.[field.trim()];
1068
+ setPrevious(Array.isArray(values) ? values : []);
1069
+ }
1070
+ add(await write([value]));
1071
+ add(await read(`search for ${itemId} (after)`));
1072
+ };
1073
+
1074
+ const restore = async () => {
1075
+ setOutcomes([]);
1076
+ add(await write(previous ?? []));
1077
+ add(await read(`search for ${itemId} (restored)`));
1078
+ setPrevious(null);
1079
+ };
1080
+
1081
+ // Expected to be refused: the platform never deletes the current index (needs hawksearch:index:manage, which this
1082
+ // app doesn't ask for, so it shows the scope check instead).
1083
+ const deleteCurrent = async () => {
1084
+ setOutcomes([]);
1085
+ add(await measure('index.deleteIndex(current) (expect 403 scope_missing)', async () => {
1086
+ await app.hawksearch.index.deleteIndex(indexName || 'x');
1087
+ return { summary: 'unexpectedly allowed' };
1088
+ }));
1089
+ };
1090
+
1091
+ return (
1092
+ <Card title="Item writes (hawksearch:index:items)">
1093
+ <p className="muted">
1094
+ Writes go straight into the chosen index. On the current index that&apos;s this engine&apos;s live search: use a test engine and a test item.
1095
+ </p>
1096
+ <div className="buttons">
1097
+ <button onClick={load}>Load indexes</button>
1098
+ <select value={indexName} onChange={(e) => setIndexName(e.target.value)} disabled={!indexNames.length}>
1099
+ {indexNames.map((n) => (
1100
+ <option key={n}>{n}</option>
1101
+ ))}
1102
+ </select>
1103
+ </div>
1104
+ <div className="buttons">
1105
+ <label>
1106
+ Primary key <input value={primaryKey} onChange={(e) => setPrimaryKey(e.target.value)} size={8} />
1107
+ </label>
1108
+ <label>
1109
+ Item <input value={itemId} onChange={(e) => setItemId(e.target.value)} placeholder="e.g. sku-1" />
1110
+ </label>
1111
+ <label>
1112
+ Field <input value={field} onChange={(e) => setField(e.target.value)} placeholder="a field defined in HawkSearch" />
1113
+ </label>
1114
+ <label>
1115
+ Value <input value={value} onChange={(e) => setValue(e.target.value)} />
1116
+ </label>
1117
+ </div>
1118
+ <label>
1119
+ <input type="checkbox" checked={understood} onChange={(e) => setUnderstood(e.target.checked)} /> I&apos;m on a test engine: change this item now
1120
+ </label>
1121
+ <div className="buttons">
1122
+ <button onClick={update} disabled={!ready}>
1123
+ Update the field
1124
+ </button>
1125
+ <button onClick={restore} disabled={!ready || previous === null}>
1126
+ Put the old value back{previous !== null ? ` (${JSON.stringify(previous)})` : ''}
1127
+ </button>
1128
+ <button onClick={deleteCurrent}>Try deleting the index</button>
1129
+ </div>
1130
+ <Results outcomes={outcomes} />
1131
+ </Card>
1132
+ );
1133
+ }
1134
+
1135
+ /** Recommendations (hawksearch:recommendations): one widget, for an optional product. */
1136
+ /** app.config: the values declared in "config" in hawk-app.json, set with `hawk-app config set` or in the portal. */
1137
+ function ConfigTab({ app }: { app: HawkApp }) {
1138
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1139
+
1140
+ const check = async () => {
1141
+ const results: Outcome[] = [];
1142
+ results.push(
1143
+ await measure('config.all()', async () => {
1144
+ const all = await app.config.all();
1145
+ return { summary: `${Object.keys(all).length} keys: ${Object.keys(all).join(', ') || '(none declared)'}`, detail: all };
1146
+ }),
1147
+ );
1148
+ results.push(
1149
+ await measure("config.get('showcaseGreeting')", async () => {
1150
+ const greeting = await app.config.get<string>('showcaseGreeting');
1151
+ return { summary: greeting === null ? 'null (not set, no default)' : JSON.stringify(greeting) };
1152
+ }),
1153
+ );
1154
+ results.push(
1155
+ await measure("config.get('showcaseNotifyEmails')", async () => {
1156
+ const emails = await app.config.get<string[]>('showcaseNotifyEmails');
1157
+ return { summary: emails === null ? 'null (set it with hawk-app config set showcaseNotifyEmails a@b.com)' : `${emails.length} addresses`, detail: emails };
1158
+ }),
1159
+ );
1160
+ results.push(
1161
+ await measure("config.get('notDeclared')", async () => {
1162
+ const value = await app.config.get('notDeclared');
1163
+ if (value !== null) throw new Error(`expected null, got ${JSON.stringify(value)}`);
1164
+ return { summary: 'null, as expected' };
1165
+ }),
1166
+ );
1167
+ results.push(
1168
+ await measure("config.get('') (refused before calling)", async () => {
1169
+ let refused = false;
1170
+ try {
1171
+ await app.config.get('');
1172
+ } catch (e) {
1173
+ refused = e instanceof TypeError;
1174
+ }
1175
+ if (!refused) throw new Error('unexpectedly allowed');
1176
+ return { summary: 'TypeError, as expected' };
1177
+ }),
1178
+ );
1179
+ results.push(
1180
+ await measure("functions.call('echo').config", async () => {
1181
+ const echoed = await app.functions.call<{ config?: Record<string, unknown> }>('echo', { from: 'config tab' });
1182
+ return { summary: `${Object.keys(echoed.config ?? {}).length} keys in ctx.config`, detail: echoed.config };
1183
+ }),
1184
+ );
1185
+ setOutcomes(results);
1186
+ };
1187
+
1188
+ return (
1189
+ <Card title="App config">
1190
+ <p className="muted">
1191
+ Values for the keys in "config" in hawk-app.json: this client's value, else the global one, else the default. Set them with{' '}
1192
+ <code>npx hawk-app config set showcaseGreeting "Hi" [--client &lt;id&gt;]</code> or in the Hawk Analytics portal; they apply within about 30
1193
+ seconds. Not secret: they reach the browser.
1194
+ </p>
1195
+ <button onClick={check}>Run</button>
1196
+ <Results outcomes={outcomes} />
1197
+ </Card>
1198
+ );
1199
+ }
1200
+
1201
+ function StorageTab({ app }: { app: HawkApp }) {
1202
+ const [notes, setNotes] = useState<StorageListEntry<{ text: string; at: string }>[]>([]);
1203
+ const [text, setText] = useState('');
1204
+ const [usage, setUsage] = useState<StorageUsage | null>(null);
1205
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1206
+ const record = (outcome: Outcome) => setOutcomes((previous) => [outcome, ...previous].slice(0, 20));
1207
+
1208
+ // Notes are shared by everyone at this client who opens the showcase: keys note#<time>, so they list in order.
1209
+ const refresh = async () =>
1210
+ record(
1211
+ await measure('storage.list(note#) + usage()', async () => {
1212
+ const page = await app.storage.list<{ text: string; at: string }>({ prefix: 'note#', values: true, limit: 50 });
1213
+ setNotes(page.items);
1214
+ setUsage(await app.storage.usage());
1215
+ return { summary: `${page.items.length} notes${page.cursor ? ' (more)' : ''}`, detail: page };
1216
+ }),
1217
+ );
1218
+
1219
+ const add = async () => {
1220
+ const at = new Date().toISOString();
1221
+ record(
1222
+ await measure('storage.put(note#…, ttlDays 7)', async () => {
1223
+ const result = await app.storage.put(`note#${at}`, { text: text.trim(), at }, { ttlDays: 7, ifVersion: 0 });
1224
+ return { summary: `v${result.version}, expires ${result.expiresAt?.slice(0, 10)}`, detail: result };
1225
+ }),
1226
+ );
1227
+ setText('');
1228
+ await refresh();
1229
+ };
1230
+
1231
+ const remove = async (key: string, version: number) => {
1232
+ record(await measure(`storage.delete(${key})`, async () => (await app.storage.delete(key, { ifVersion: version }), { summary: 'deleted' })));
1233
+ await refresh();
1234
+ };
1235
+
1236
+ /** Every storage call once, on keys of its own (check#…), cleaned up afterwards. */
1237
+ const check = async () => {
1238
+ const key = `check#${Date.now()}`;
1239
+ const results: Outcome[] = [];
1240
+ results.push(await measure('put (new, ifVersion 0)', async () => ({ summary: `v${(await app.storage.put(key, { n: 1 }, { ifVersion: 0 })).version}` })));
1241
+ results.push(await measure('get', async () => ({ summary: JSON.stringify((await app.storage.get(key))?.value) })));
1242
+ results.push(await expectError('put with a stale ifVersion', 409, 'version_conflict', () => app.storage.put(key, { n: 2 }, { ifVersion: 0 })));
1243
+ results.push(await measure('put (ifVersion 1)', async () => ({ summary: `v${(await app.storage.put(key, { n: 2 }, { ifVersion: 1 })).version}` })));
1244
+ results.push(
1245
+ await measure('batch (get, put, delete)', async () => {
1246
+ const batch = await app.storage.batch({ get: [key, `${key}-missing`], put: [{ key: `${key}-b`, value: 'b' }], delete: [`${key}-b`] });
1247
+ return { summary: `get ${batch.get.map((g) => (g.item ? 'found' : 'missing')).join('/')}, put ${batch.put[0].status}, delete ${batch.delete[0].status}`, detail: batch };
1248
+ }),
1249
+ );
1250
+ results.push(await measure('list (prefix)', async () => ({ summary: `${(await app.storage.list({ prefix: key })).items.length} item` })));
1251
+ results.push(await measure('delete', async () => (await app.storage.delete(key), { summary: 'deleted' })));
1252
+ results.push(await measure('get (deleted)', async () => ({ summary: (await app.storage.get(key)) === null ? 'null, as expected' : 'still there!' })));
1253
+ results.push(
1254
+ await measure('invalid key #usage (refused before calling)', async () => {
1255
+ const refused = await app.storage.get('#usage').then(() => false, (e) => e instanceof TypeError);
1256
+ if (!refused) throw new Error('unexpectedly allowed');
1257
+ return { summary: 'TypeError, as expected' };
1258
+ }),
1259
+ );
1260
+ setOutcomes(results);
1261
+ setUsage(await app.storage.usage());
1262
+ };
1263
+
1264
+ return (
1265
+ <>
1266
+ <Card title="Notes (storage)">
1267
+ <p className="muted">Shared by everyone at this client using the showcase. Each note expires after 7 days.</p>
1268
+ <input value={text} onChange={(e) => setText(e.target.value)} placeholder="A note" />{' '}
1269
+ <button onClick={add} disabled={!text.trim()}>
1270
+ Add
1271
+ </button>{' '}
1272
+ <button onClick={refresh}>Refresh</button>
1273
+ <ul>
1274
+ {notes.map((n) => (
1275
+ <li key={n.key}>
1276
+ {n.value?.text} <span className="muted">· {n.value?.at.slice(0, 16).replace('T', ' ')} · v{n.version}</span>{' '}
1277
+ <button onClick={() => remove(n.key, n.version)}>Delete</button>
1278
+ </li>
1279
+ ))}
1280
+ </ul>
1281
+ {usage && (
1282
+ <p className="muted">
1283
+ Usage: {usage.items} / {usage.maxItems} items, {(usage.bytes / 1024).toFixed(1)} KB / {Math.round(usage.maxBytes / 1048576)} MB
1284
+ </p>
1285
+ )}
1286
+ </Card>
1287
+ <Card title="Check every call">
1288
+ <button onClick={check}>Run</button>
1289
+ <Results outcomes={outcomes} />
1290
+ </Card>
1291
+ <StorageFilesCard app={app} />
1292
+ </>
1293
+ );
1294
+ }
1295
+
1296
+ /** storage.files: upload a file (kept 7 days), open or delete it, and a round trip through every call. */
1297
+ function StorageFilesCard({ app }: { app: HawkApp }) {
1298
+ const [files, setFiles] = useState<StorageFileEntry[]>([]);
1299
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1300
+ const record = (outcome: Outcome) => setOutcomes((previous) => [outcome, ...previous].slice(0, 20));
1301
+
1302
+ const refresh = async () =>
1303
+ record(
1304
+ await measure('files.list(uploads/)', async () => {
1305
+ const page = await app.storage.files.list({ prefix: 'uploads/' });
1306
+ setFiles(page.files);
1307
+ return { summary: `${page.files.length} files${page.cursor ? ' (more)' : ''}`, detail: page };
1308
+ }),
1309
+ );
1310
+
1311
+ const upload = async (file: File) => {
1312
+ // Paths allow A–Z a–z 0–9 . _ - only.
1313
+ const path = `uploads/${Date.now()}-${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}`;
1314
+ record(
1315
+ await measure(`files.upload(${path})`, async () => {
1316
+ const result = await app.storage.files.upload(path, file, { ttlDays: 7 });
1317
+ return { summary: `${result.size} bytes, ${result.contentType}`, detail: result };
1318
+ }),
1319
+ );
1320
+ await refresh();
1321
+ };
1322
+
1323
+ const open = async (path: string) =>
1324
+ record(
1325
+ await measure(`files.url(${path})`, async () => {
1326
+ const { url, expiresAt } = await app.storage.files.url(path, { download: path.split('/').pop() });
1327
+ await app.navigate({ intent: 'external', url });
1328
+ return { summary: `opened, link works until ${expiresAt.slice(11, 16)} UTC` };
1329
+ }),
1330
+ );
1331
+
1332
+ const remove = async (path: string) => {
1333
+ record(await measure(`files.delete(${path})`, async () => (await app.storage.files.delete(path), { summary: 'deleted' })));
1334
+ await refresh();
1335
+ };
1336
+
1337
+ /** upload → url → download (same bytes back) → list → delete → url is 404, on a path of its own. */
1338
+ const check = async () => {
1339
+ const path = `checks/${Date.now()}.txt`;
1340
+ const text = `showcase check ${new Date().toISOString()}`;
1341
+ const results: Outcome[] = [];
1342
+ results.push(await measure('upload (text, ttlDays 1)', async () => ({ summary: `${(await app.storage.files.upload(path, text, { ttlDays: 1 })).size} bytes` })));
1343
+ results.push(await measure('url', async () => ({ summary: `expires ${(await app.storage.files.url(path)).expiresAt}` })));
1344
+ results.push(
1345
+ await measure('download (same bytes back)', async () => {
1346
+ const back = await (await app.storage.files.download(path)).text();
1347
+ if (back !== text) throw new Error(`got ${JSON.stringify(back)}`);
1348
+ return { summary: 'matches' };
1349
+ }),
1350
+ );
1351
+ results.push(await measure('list (prefix)', async () => ({ summary: `${(await app.storage.files.list({ prefix: path })).files.length} file` })));
1352
+ results.push(await measure('delete', async () => (await app.storage.files.delete(path), { summary: 'deleted' })));
1353
+ results.push(await expectError('url after delete', 404, 'not_found', () => app.storage.files.url(path)));
1354
+ results.push(
1355
+ await measure('usage', async () => {
1356
+ const usage = await app.storage.usage();
1357
+ return { summary: `${usage.files} files, ${(usage.fileBytes / 1024).toFixed(1)} KB of ${Math.round(usage.maxFileBytes / 1073741824)} GB` };
1358
+ }),
1359
+ );
1360
+ setOutcomes(results);
1361
+ };
1362
+
1363
+ return (
1364
+ <Card title="Files (storage.files)">
1365
+ <p className="muted">Uploads are shared by everyone at this client and deleted after 7 days.</p>
1366
+ <input type="file" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} /> <button onClick={refresh}>Refresh</button>{' '}
1367
+ <button onClick={check}>Check every call</button>
1368
+ <ul>
1369
+ {files.map((f) => (
1370
+ <li key={f.path}>
1371
+ {f.path} <span className="muted">· {(f.size / 1024).toFixed(1)} KB</span> <button onClick={() => open(f.path)}>Open</button>{' '}
1372
+ <button onClick={() => remove(f.path)}>Delete</button>
1373
+ </li>
1374
+ ))}
1375
+ </ul>
1376
+ <Results outcomes={outcomes} />
1377
+ </Card>
1378
+ );
1379
+ }
1380
+
1381
+ function RecommendationsTab({ app }: { app: HawkApp }) {
1382
+ const [widgetGuid, setWidgetGuid] = useState('');
1383
+ const [uniqueId, setUniqueId] = useState('');
1384
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1385
+
1386
+ const run = async () =>
1387
+ setOutcomes([
1388
+ await measure('hawksearch.recommendations()', async () => {
1389
+ const response = await app.hawksearch.recommendations({ widgetUids: [{ widgetGuid: widgetGuid.trim(), uniqueId: uniqueId.trim() || null }] });
1390
+ const items = response.widgetItems?.flatMap((w) => w.recommendationItems ?? []) ?? [];
1391
+ return { summary: `${response.widgetItems?.length ?? 0} widgets, ${items.length} items`, detail: response };
1392
+ }),
1393
+ ]);
1394
+
1395
+ return (
1396
+ <Card title="Recommendations (hawksearch:recommendations)">
1397
+ <label>
1398
+ Widget guid <input value={widgetGuid} onChange={(e) => setWidgetGuid(e.target.value)} placeholder="from HawkSearch's Recommendations page" />
1399
+ </label>{' '}
1400
+ <label>
1401
+ Product id <input value={uniqueId} onChange={(e) => setUniqueId(e.target.value)} placeholder="optional" />
1402
+ </label>{' '}
1403
+ <button onClick={run} disabled={!widgetGuid.trim()}>
1404
+ Get
1405
+ </button>
1406
+ <Results outcomes={outcomes} />
1407
+ </Card>
1408
+ );
1409
+ }
1410
+
1411
+ function HostTab({ app }: { app: HawkApp }) {
1412
+ const [outcomes, setOutcomes] = useState<Outcome[]>([]);
1413
+ const [tall, setTall] = useState(false);
1414
+ const record = async (label: string, call: () => Promise<void>) =>
1415
+ setOutcomes([await measure(label, async () => (await call(), { summary: 'sent' })), ...outcomes].slice(0, 10));
1416
+
1417
+ return (
1418
+ <Card title="Host messages">
1419
+ <div className="buttons">
1420
+ {(['info', 'success', 'warning', 'error'] as const).map((variant) => (
1421
+ <button key={variant} onClick={() => record(`toast ${variant}`, () => app.toast(`Showcase: ${variant} toast`, variant))}>
1422
+ Toast {variant}
1423
+ </button>
1424
+ ))}
1425
+ <button onClick={() => record('setTitle', () => app.setTitle(`Showcase · ${new Date().toLocaleTimeString()}`))}>Set title</button>
1426
+ <button onClick={() => record('navigate settings', () => app.navigate({ intent: 'settings' }))}>Navigate: settings</button>
1427
+ <button onClick={() => record('navigate external', () => app.navigate({ intent: 'external', url: 'https://www.hawksearch.com/' }))}>
1428
+ Navigate: external
1429
+ </button>
1430
+ <button onClick={() => setTall(!tall)}>{tall ? 'Shrink' : 'Grow'} (auto-resize)</button>
1431
+ </div>
1432
+ {tall && <div className="tall">Tall content: the frame should grow to fit.</div>}
1433
+ <Results outcomes={outcomes} />
1434
+ </Card>
1435
+ );
1436
+ }