@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.
- package/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- 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, "job": 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'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's live search from its feed. It needs the engine'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's this engine'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'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 <id>]</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, "job": 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'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's live search from its feed. It needs the engine'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's this engine'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'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 <id>]</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
|
+
}
|