@entrinsik/vite-plugin-informer 1.0.4 → 1.0.5
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/bin/init.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile, writeFile, mkdir,
|
|
4
|
-
import { resolve,
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
|
|
4
|
+
import { resolve, basename } from 'node:path';
|
|
6
5
|
import { createInterface } from 'node:readline';
|
|
7
6
|
import { randomUUID } from 'node:crypto';
|
|
8
7
|
|
|
9
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
8
|
const cwd = process.cwd();
|
|
11
9
|
|
|
12
10
|
/**
|
|
@@ -279,10 +277,7 @@ INFORMER_API_KEY=your-api-key
|
|
|
279
277
|
console.log('Created data-access.yaml (configure API access for your report)');
|
|
280
278
|
}
|
|
281
279
|
|
|
282
|
-
// 10.
|
|
283
|
-
await copySkillFiles();
|
|
284
|
-
|
|
285
|
-
// 11. Add .env to .gitignore if not present
|
|
280
|
+
// 10. Add .env to .gitignore if not present
|
|
286
281
|
await updateGitignore();
|
|
287
282
|
|
|
288
283
|
console.log('\nSetup complete!\n');
|
|
@@ -291,8 +286,8 @@ INFORMER_API_KEY=your-api-key
|
|
|
291
286
|
console.log(' 2. Update data-access.yaml with the datasets/APIs your report needs');
|
|
292
287
|
console.log(' 3. Run: npm install');
|
|
293
288
|
console.log(' 4. Run: npm run dev');
|
|
294
|
-
console.log(' 5.
|
|
295
|
-
console.log('
|
|
289
|
+
console.log(' 5. Install the Claude skill: /plugin marketplace add entrinsik-org/claude-plugins');
|
|
290
|
+
console.log(' 6. Then: /plugin install magic-reports@entrinsik-plugins');
|
|
296
291
|
console.log('');
|
|
297
292
|
}
|
|
298
293
|
|
|
@@ -364,34 +359,6 @@ export default defineConfig({
|
|
|
364
359
|
console.log(`Updated ${configPath.split('/').pop()} with informer plugin`);
|
|
365
360
|
}
|
|
366
361
|
|
|
367
|
-
async function copySkillFiles() {
|
|
368
|
-
const skillSrc = resolve(__dirname, '..', 'templates', 'skill');
|
|
369
|
-
const skillDest = resolve(cwd, '.claude', 'skills', 'informer');
|
|
370
|
-
|
|
371
|
-
// Create destination directory
|
|
372
|
-
await mkdir(skillDest, { recursive: true });
|
|
373
|
-
|
|
374
|
-
// Copy files recursively
|
|
375
|
-
await copyDir(skillSrc, skillDest);
|
|
376
|
-
console.log('Copied Claude skill files to .claude/skills/informer/');
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async function copyDir(src, dest) {
|
|
380
|
-
const entries = await readdir(src, { withFileTypes: true });
|
|
381
|
-
|
|
382
|
-
for (const entry of entries) {
|
|
383
|
-
const srcPath = join(src, entry.name);
|
|
384
|
-
const destPath = join(dest, entry.name);
|
|
385
|
-
|
|
386
|
-
if (entry.isDirectory()) {
|
|
387
|
-
await mkdir(destPath, { recursive: true });
|
|
388
|
-
await copyDir(srcPath, destPath);
|
|
389
|
-
} else {
|
|
390
|
-
await cp(srcPath, destPath);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
362
|
async function updateGitignore() {
|
|
396
363
|
const gitignorePath = resolve(cwd, '.gitignore');
|
|
397
364
|
let content = '';
|
package/index.d.ts
ADDED
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Vite plugin and publish tool for local Magic Report development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
10
13
|
"./deploy": "./src/deploy.js"
|
|
11
14
|
},
|
|
12
15
|
"bin": {
|
|
@@ -17,7 +20,7 @@
|
|
|
17
20
|
"files": [
|
|
18
21
|
"src",
|
|
19
22
|
"bin",
|
|
20
|
-
"
|
|
23
|
+
"index.d.ts"
|
|
21
24
|
],
|
|
22
25
|
"peerDependencies": {
|
|
23
26
|
"vite": ">=5.0.0"
|
package/templates/skill/SKILL.md
DELETED
|
@@ -1,436 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: informer
|
|
3
|
-
description: Building Magic Reports with local Vite development. Covers the dev/publish workflow and key Informer APIs for datasets, queries, and integrations.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Magic Report Development
|
|
7
|
-
|
|
8
|
-
## What is a Magic Report?
|
|
9
|
-
|
|
10
|
-
A Magic Report is a custom HTML/JS/CSS application that runs inside Informer. It can:
|
|
11
|
-
- Query Informer datasets (Elasticsearch-indexed data)
|
|
12
|
-
- Execute saved queries
|
|
13
|
-
- Make authenticated requests to external APIs via integrations (Salesforce, etc.)
|
|
14
|
-
- Render charts, tables, and interactive visualizations
|
|
15
|
-
|
|
16
|
-
Reports are stored in Informer libraries and served through the Informer UI.
|
|
17
|
-
|
|
18
|
-
## Local Development Workflow
|
|
19
|
-
|
|
20
|
-
### Development Mode (`npm run dev`)
|
|
21
|
-
|
|
22
|
-
The Vite plugin proxies `/api/*` requests to your Informer server with Basic auth. This means:
|
|
23
|
-
- Your code makes fetch calls to `/api/...` (no host needed)
|
|
24
|
-
- The plugin adds authentication headers automatically
|
|
25
|
-
- You get hot reload while working against real Informer data
|
|
26
|
-
|
|
27
|
-
Configuration is in `.env`:
|
|
28
|
-
```
|
|
29
|
-
INFORMER_URL=http://localhost:3000
|
|
30
|
-
INFORMER_API_KEY=your-api-key
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Or use basic auth:
|
|
34
|
-
```
|
|
35
|
-
INFORMER_URL=http://localhost:3000
|
|
36
|
-
INFORMER_USER=admin
|
|
37
|
-
INFORMER_PASS=yourpassword
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
### Deploying (`npm run deploy`)
|
|
41
|
-
|
|
42
|
-
Builds your project and uploads to an Informer library:
|
|
43
|
-
1. Creates/finds a Magic Report by slug (from package.json `name`)
|
|
44
|
-
2. Snapshots the library for rollback
|
|
45
|
-
3. Clears existing files
|
|
46
|
-
4. Uploads all built assets from `dist/`
|
|
47
|
-
5. Uploads `data-access.yaml` from project root (if it exists)
|
|
48
|
-
6. Report is viewable at `/reports/r/{owner}:{slug}`
|
|
49
|
-
|
|
50
|
-
### Package.json Configuration
|
|
51
|
-
|
|
52
|
-
The `informer` section in `package.json` controls deploy metadata:
|
|
53
|
-
|
|
54
|
-
```json
|
|
55
|
-
{
|
|
56
|
-
"informer": {
|
|
57
|
-
"name": "Sales Dashboard",
|
|
58
|
-
"description": "Regional sales performance overview",
|
|
59
|
-
"icon": "BarChart",
|
|
60
|
-
"id": "a1b2c3d4-..."
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
| Field | Description |
|
|
66
|
-
|-------|-------------|
|
|
67
|
-
| `name` | Display name in Informer (falls back to package `name`) |
|
|
68
|
-
| `description` | Report description |
|
|
69
|
-
| `icon` | Material Icons name in PascalCase (e.g. `"TrendingUp"`, `"PieChart"`, `"Assessment"`) |
|
|
70
|
-
| `id` | Report UUID (auto-saved after first deploy) |
|
|
71
|
-
|
|
72
|
-
## Discovering Resources
|
|
73
|
-
|
|
74
|
-
Once `.env` is configured, Claude can query the Informer API directly to help you find available resources. Ask Claude to look up:
|
|
75
|
-
|
|
76
|
-
- **Integrations**: `curl -u $USER:$PASS "$INFORMER_URL/api/integrations"` - Find integration slugs for QuickBooks, Salesforce, etc.
|
|
77
|
-
- **Datasets**: `curl -u $USER:$PASS "$INFORMER_URL/api/datasets-list"` - Find dataset IDs and field names
|
|
78
|
-
- **Queries**: `curl -u $USER:$PASS "$INFORMER_URL/api/queries-list"` - Find saved query IDs
|
|
79
|
-
- **Datasources**: `curl -u $USER:$PASS "$INFORMER_URL/api/datasources"` - Find SQL datasource IDs
|
|
80
|
-
|
|
81
|
-
This helps you find the correct IDs/slugs to use in your code and `data-access.yaml`. Just ask Claude to "show me available integrations" or "find the QuickBooks integration slug".
|
|
82
|
-
|
|
83
|
-
## Key APIs
|
|
84
|
-
|
|
85
|
-
All endpoints are relative to `/api`. In dev mode, the Vite proxy handles auth.
|
|
86
|
-
|
|
87
|
-
### List Datasets
|
|
88
|
-
|
|
89
|
-
```javascript
|
|
90
|
-
const response = await fetch('/api/datasets-list');
|
|
91
|
-
const datasets = await response.json();
|
|
92
|
-
// Returns: [{ id, name, description, records, size, ... }, ...]
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
Use this to discover available datasets. Each dataset has:
|
|
96
|
-
- `id` - UUID or natural ID like `admin:sales-data`
|
|
97
|
-
- `name` - Display name
|
|
98
|
-
- `records` - Approximate record count
|
|
99
|
-
|
|
100
|
-
### Search Dataset (Elasticsearch)
|
|
101
|
-
|
|
102
|
-
```javascript
|
|
103
|
-
const response = await fetch(`/api/datasets/${datasetId}/_search`, {
|
|
104
|
-
method: 'POST',
|
|
105
|
-
headers: { 'Content-Type': 'application/json' },
|
|
106
|
-
body: JSON.stringify({
|
|
107
|
-
query: { match_all: {} },
|
|
108
|
-
size: 100,
|
|
109
|
-
from: 0,
|
|
110
|
-
_source: ['field1', 'field2'], // Optional: limit fields returned
|
|
111
|
-
sort: [{ field1: 'desc' }], // Optional: sort order
|
|
112
|
-
aggs: { // Optional: aggregations
|
|
113
|
-
total: { sum: { field: 'amount' } }
|
|
114
|
-
}
|
|
115
|
-
})
|
|
116
|
-
});
|
|
117
|
-
const result = await response.json();
|
|
118
|
-
|
|
119
|
-
// Response structure:
|
|
120
|
-
// result.hits.total - total matching records
|
|
121
|
-
// result.hits.hits - array of { _source: { field1, field2, ... } }
|
|
122
|
-
// result.aggregations - aggregation results (if requested)
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
**Common query patterns:**
|
|
126
|
-
|
|
127
|
-
```javascript
|
|
128
|
-
// Filter by exact value
|
|
129
|
-
{ query: { bool: { filter: [{ term: { status: 'active' } }] } } }
|
|
130
|
-
|
|
131
|
-
// Filter by range
|
|
132
|
-
{ query: { bool: { filter: [{ range: { amount: { gte: 1000 } } }] } } }
|
|
133
|
-
|
|
134
|
-
// Date range
|
|
135
|
-
{ query: { bool: { filter: [{ range: { date: { gte: '2024-01-01', lte: '2024-12-31' } } }] } } }
|
|
136
|
-
|
|
137
|
-
// Multiple filters (AND)
|
|
138
|
-
{ query: { bool: { filter: [
|
|
139
|
-
{ term: { region: 'North' } },
|
|
140
|
-
{ range: { amount: { gte: 1000 } } }
|
|
141
|
-
] } } }
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
**Common aggregations:**
|
|
145
|
-
|
|
146
|
-
```javascript
|
|
147
|
-
// Sum, avg, min, max
|
|
148
|
-
{ aggs: { total: { sum: { field: 'amount' } } } }
|
|
149
|
-
|
|
150
|
-
// Group by field
|
|
151
|
-
{ aggs: { by_region: { terms: { field: 'region', size: 50 } } } }
|
|
152
|
-
|
|
153
|
-
// Group with nested metric
|
|
154
|
-
{ aggs: {
|
|
155
|
-
by_region: {
|
|
156
|
-
terms: { field: 'region', size: 50 },
|
|
157
|
-
aggs: { total: { sum: { field: 'amount' } } }
|
|
158
|
-
}
|
|
159
|
-
} }
|
|
160
|
-
|
|
161
|
-
// Date histogram
|
|
162
|
-
{ aggs: {
|
|
163
|
-
by_month: {
|
|
164
|
-
date_histogram: { field: 'date', calendar_interval: 'month' },
|
|
165
|
-
aggs: { total: { sum: { field: 'amount' } } }
|
|
166
|
-
}
|
|
167
|
-
} }
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### List Queries
|
|
171
|
-
|
|
172
|
-
```javascript
|
|
173
|
-
const response = await fetch('/api/queries-list');
|
|
174
|
-
const queries = await response.json();
|
|
175
|
-
// Returns: [{ id, name, description, ... }, ...]
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
### Execute Query
|
|
179
|
-
|
|
180
|
-
```javascript
|
|
181
|
-
const response = await fetch(`/api/queries/${queryId}/_execute`, {
|
|
182
|
-
method: 'POST',
|
|
183
|
-
headers: { 'Content-Type': 'application/json' },
|
|
184
|
-
body: JSON.stringify({
|
|
185
|
-
parameters: { param1: 'value1' } // Optional query parameters
|
|
186
|
-
})
|
|
187
|
-
});
|
|
188
|
-
const result = await response.json();
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
### List Integrations
|
|
192
|
-
|
|
193
|
-
```javascript
|
|
194
|
-
const response = await fetch('/api/integrations');
|
|
195
|
-
const result = await response.json();
|
|
196
|
-
// result.items = [{ id, name, slug, type, ... }, ...]
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
Integrations are authenticated connections to external APIs (Salesforce, REST APIs, etc.).
|
|
200
|
-
|
|
201
|
-
### Make Integration Request
|
|
202
|
-
|
|
203
|
-
```javascript
|
|
204
|
-
const response = await fetch(`/api/integrations/${slugOrId}/request`, {
|
|
205
|
-
method: 'POST',
|
|
206
|
-
headers: { 'Content-Type': 'application/json' },
|
|
207
|
-
body: JSON.stringify({
|
|
208
|
-
url: '/data/v59.0/query', // Path relative to integration's base URL
|
|
209
|
-
method: 'GET', // HTTP method
|
|
210
|
-
params: { q: 'SELECT Id FROM Account' }, // Query params
|
|
211
|
-
data: { /* body for POST/PUT */ }, // Request body
|
|
212
|
-
headers: { /* extra headers */ } // Additional headers
|
|
213
|
-
})
|
|
214
|
-
});
|
|
215
|
-
const result = await response.json();
|
|
216
|
-
|
|
217
|
-
// Response structure:
|
|
218
|
-
// result.status - HTTP status code
|
|
219
|
-
// result.data - response body from the external API
|
|
220
|
-
// result.error - true if upstream returned an error status
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
**Salesforce example:**
|
|
224
|
-
```javascript
|
|
225
|
-
const response = await fetch('/api/integrations/salesforce/request', {
|
|
226
|
-
method: 'POST',
|
|
227
|
-
headers: { 'Content-Type': 'application/json' },
|
|
228
|
-
body: JSON.stringify({
|
|
229
|
-
url: '/data/v59.0/query',
|
|
230
|
-
method: 'GET',
|
|
231
|
-
params: {
|
|
232
|
-
q: "SELECT Id, Name, Amount FROM Opportunity WHERE StageName = 'Closed Won'"
|
|
233
|
-
}
|
|
234
|
-
})
|
|
235
|
-
});
|
|
236
|
-
const result = await response.json();
|
|
237
|
-
const records = result.data.records;
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
## Data Access Configuration
|
|
241
|
-
|
|
242
|
-
When your report is published and shared, you must declare which APIs it needs access to. Create a `data-access.yaml` file in your project root (it will be published with your report).
|
|
243
|
-
|
|
244
|
-
**Important:** Without this file, all API access is blocked when the report runs in Informer.
|
|
245
|
-
|
|
246
|
-
### Basic Example
|
|
247
|
-
|
|
248
|
-
```yaml
|
|
249
|
-
# data-access.yaml
|
|
250
|
-
|
|
251
|
-
datasets:
|
|
252
|
-
- admin:sales-data
|
|
253
|
-
- admin:customers
|
|
254
|
-
|
|
255
|
-
queries:
|
|
256
|
-
- admin:monthly-summary
|
|
257
|
-
|
|
258
|
-
integrations:
|
|
259
|
-
- salesforce
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
### With Row-Level Security
|
|
263
|
-
|
|
264
|
-
Restrict data based on the viewing user's profile:
|
|
265
|
-
|
|
266
|
-
```yaml
|
|
267
|
-
datasets:
|
|
268
|
-
# Users only see their region's data
|
|
269
|
-
- id: admin:orders
|
|
270
|
-
filter:
|
|
271
|
-
region: $user.custom.region
|
|
272
|
-
|
|
273
|
-
# Users only see their own records
|
|
274
|
-
- id: admin:sales
|
|
275
|
-
filter:
|
|
276
|
-
sales_rep: $user.username
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
### Integration with Credentials
|
|
280
|
-
|
|
281
|
-
Pass user-specific credentials to external APIs:
|
|
282
|
-
|
|
283
|
-
```yaml
|
|
284
|
-
integrations:
|
|
285
|
-
- id: partner-api
|
|
286
|
-
headers:
|
|
287
|
-
Authorization: Bearer $user.custom.partnerToken
|
|
288
|
-
params:
|
|
289
|
-
client_id: $tenant.id
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
### Available Variables
|
|
293
|
-
|
|
294
|
-
| Variable | Description |
|
|
295
|
-
|----------|-------------|
|
|
296
|
-
| `$user.username` | Login name |
|
|
297
|
-
| `$user.email` | Email address |
|
|
298
|
-
| `$user.displayName` | Full name |
|
|
299
|
-
| `$user.custom.xxx` | Custom user field |
|
|
300
|
-
| `$tenant.id` | Tenant ID |
|
|
301
|
-
| `$report.id` | Report UUID |
|
|
302
|
-
|
|
303
|
-
### Resource Types
|
|
304
|
-
|
|
305
|
-
| Type | API Access Granted |
|
|
306
|
-
|------|-------------------|
|
|
307
|
-
| `datasets` | `_search`, `fields` |
|
|
308
|
-
| `queries` | `_execute` |
|
|
309
|
-
| `datasources` | `_query` |
|
|
310
|
-
| `integrations` | `request` |
|
|
311
|
-
| `libraries` | `contents/*` |
|
|
312
|
-
|
|
313
|
-
For edge cases, you can also whitelist raw API paths:
|
|
314
|
-
|
|
315
|
-
```yaml
|
|
316
|
-
apis:
|
|
317
|
-
- POST /api/custom/endpoint
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
## Report Context
|
|
321
|
-
|
|
322
|
-
When running inside Informer (not dev mode), the report receives context:
|
|
323
|
-
|
|
324
|
-
```javascript
|
|
325
|
-
const reportId = window.__INFORMER__?.report?.id;
|
|
326
|
-
const reportName = window.__INFORMER__?.report?.name;
|
|
327
|
-
const theme = window.__INFORMER__?.theme; // 'light' or 'dark'
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
In dev mode, the Vite plugin mocks this with placeholder values (theme defaults to `'light'`).
|
|
331
|
-
|
|
332
|
-
### Responding to Theme
|
|
333
|
-
|
|
334
|
-
Use the theme value to adapt your report's appearance:
|
|
335
|
-
|
|
336
|
-
```javascript
|
|
337
|
-
const theme = window.__INFORMER__?.theme || 'light';
|
|
338
|
-
document.documentElement.setAttribute('data-theme', theme);
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
```css
|
|
342
|
-
:root { --bg: #ffffff; --text: #1a1a1a; }
|
|
343
|
-
[data-theme="dark"] { --bg: #1e1e1e; --text: #e0e0e0; }
|
|
344
|
-
body { background: var(--bg); color: var(--text); }
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
To override the theme in dev mode, pass `mock.theme` in `vite.config.js`:
|
|
348
|
-
|
|
349
|
-
```javascript
|
|
350
|
-
import informer from 'vite-plugin-informer';
|
|
351
|
-
|
|
352
|
-
export default {
|
|
353
|
-
plugins: [informer({ mock: { theme: 'dark' } })]
|
|
354
|
-
};
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
## PDF Export
|
|
358
|
-
|
|
359
|
-
Reports can be exported to PDF via `POST /api/reports/{id}/_print`.
|
|
360
|
-
|
|
361
|
-
### How it works
|
|
362
|
-
|
|
363
|
-
1. Informer opens your report in a headless browser (Puppeteer)
|
|
364
|
-
2. Waits for network requests to complete
|
|
365
|
-
3. Waits for `window.informerReady` to become `true`
|
|
366
|
-
4. Adds `.print` class to `<html>`
|
|
367
|
-
5. Captures the page as PDF using print media
|
|
368
|
-
|
|
369
|
-
### Signal when ready
|
|
370
|
-
|
|
371
|
-
Set `window.informerReady` to signal when your report is fully rendered:
|
|
372
|
-
|
|
373
|
-
```javascript
|
|
374
|
-
// Start of app
|
|
375
|
-
window.informerReady = false;
|
|
376
|
-
|
|
377
|
-
// After all charts/content rendered
|
|
378
|
-
window.informerReady = true;
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
### Rendering details
|
|
382
|
-
|
|
383
|
-
- **Print media is used** - Standard `@media print` CSS rules apply
|
|
384
|
-
- **`.print` class added** - Informer adds a `.print` class to `<html>` for additional targeting
|
|
385
|
-
- **Viewport is 1200px** by default (configurable via `viewportWidth` option)
|
|
386
|
-
- **Box shadows are removed** - They render as grey boxes in PDFs
|
|
387
|
-
- **Colors are preserved** - `print-color-adjust: exact` is applied automatically
|
|
388
|
-
|
|
389
|
-
### Print CSS
|
|
390
|
-
|
|
391
|
-
Use standard `@media print` rules or the `.print` class:
|
|
392
|
-
|
|
393
|
-
```css
|
|
394
|
-
/* Standard print media query */
|
|
395
|
-
@media print {
|
|
396
|
-
body {
|
|
397
|
-
background: white;
|
|
398
|
-
color: black;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
.no-print {
|
|
402
|
-
display: none;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/* Or use the .print class (added by Informer) */
|
|
407
|
-
.print .no-print {
|
|
408
|
-
display: none;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/* Avoid page breaks inside elements */
|
|
412
|
-
.chart-container {
|
|
413
|
-
break-inside: avoid;
|
|
414
|
-
}
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
### Print API options
|
|
418
|
-
|
|
419
|
-
```javascript
|
|
420
|
-
await fetch(`/api/reports/${reportId}/_print`, {
|
|
421
|
-
method: 'POST',
|
|
422
|
-
headers: { 'Content-Type': 'application/json' },
|
|
423
|
-
body: JSON.stringify({
|
|
424
|
-
format: 'Letter', // Letter, Legal, Tabloid, A3, A4, A5
|
|
425
|
-
landscape: false,
|
|
426
|
-
viewportWidth: 1200, // 400-2400, affects responsive layouts
|
|
427
|
-
waitForReady: true, // Wait for window.informerReady
|
|
428
|
-
save: false // true = save to downloads, false = return PDF
|
|
429
|
-
})
|
|
430
|
-
});
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
## Reference Files
|
|
434
|
-
|
|
435
|
-
- `references/api-reference.md` - Detailed API documentation
|
|
436
|
-
- `references/report-templates.md` - HTML/CSS/JS starter templates
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
# API Quick Reference
|
|
2
|
-
|
|
3
|
-
All endpoints are relative to `/api`. The Vite plugin handles authentication in dev mode.
|
|
4
|
-
|
|
5
|
-
## Datasets
|
|
6
|
-
|
|
7
|
-
### GET /api/datasets-list
|
|
8
|
-
List all datasets the user can access.
|
|
9
|
-
|
|
10
|
-
```javascript
|
|
11
|
-
const datasets = await fetch('/api/datasets-list').then(r => r.json());
|
|
12
|
-
// [{ id, name, description, records, size, tags, sharing }, ...]
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
### POST /api/datasets/{id}/_search
|
|
16
|
-
Query dataset data using Elasticsearch DSL.
|
|
17
|
-
|
|
18
|
-
```javascript
|
|
19
|
-
const result = await fetch(`/api/datasets/${id}/_search`, {
|
|
20
|
-
method: 'POST',
|
|
21
|
-
headers: { 'Content-Type': 'application/json' },
|
|
22
|
-
body: JSON.stringify({
|
|
23
|
-
query: { match_all: {} },
|
|
24
|
-
size: 100,
|
|
25
|
-
from: 0,
|
|
26
|
-
_source: ['field1', 'field2'],
|
|
27
|
-
sort: [{ field1: 'desc' }],
|
|
28
|
-
aggs: { total: { sum: { field: 'amount' } } }
|
|
29
|
-
})
|
|
30
|
-
}).then(r => r.json());
|
|
31
|
-
|
|
32
|
-
// result.hits.total - count
|
|
33
|
-
// result.hits.hits - [{ _source: { ... } }, ...]
|
|
34
|
-
// result.aggregations - { total: { value: 12345 } }
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
**Query patterns:**
|
|
38
|
-
```javascript
|
|
39
|
-
// All records
|
|
40
|
-
{ query: { match_all: {} } }
|
|
41
|
-
|
|
42
|
-
// Exact match
|
|
43
|
-
{ query: { bool: { filter: [{ term: { status: 'active' } }] } } }
|
|
44
|
-
|
|
45
|
-
// Range
|
|
46
|
-
{ query: { bool: { filter: [{ range: { amount: { gte: 1000 } } }] } } }
|
|
47
|
-
|
|
48
|
-
// Date range
|
|
49
|
-
{ query: { bool: { filter: [{ range: { date: { gte: '2024-01-01', lte: '2024-12-31' } } }] } } }
|
|
50
|
-
|
|
51
|
-
// Multiple filters
|
|
52
|
-
{ query: { bool: { filter: [
|
|
53
|
-
{ term: { region: 'North' } },
|
|
54
|
-
{ range: { amount: { gte: 1000 } } }
|
|
55
|
-
] } } }
|
|
56
|
-
|
|
57
|
-
// Text search
|
|
58
|
-
{ query: { bool: { must: [{ match: { name: 'search text' } }] } } }
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
**Aggregation patterns:**
|
|
62
|
-
```javascript
|
|
63
|
-
// Metrics
|
|
64
|
-
{ aggs: { total: { sum: { field: 'amount' } } } }
|
|
65
|
-
{ aggs: { average: { avg: { field: 'amount' } } } }
|
|
66
|
-
{ aggs: { minimum: { min: { field: 'amount' } } } }
|
|
67
|
-
{ aggs: { maximum: { max: { field: 'amount' } } } }
|
|
68
|
-
{ aggs: { count: { value_count: { field: 'id' } } } }
|
|
69
|
-
{ aggs: { unique: { cardinality: { field: 'customer' } } } }
|
|
70
|
-
|
|
71
|
-
// Group by
|
|
72
|
-
{ aggs: { by_status: { terms: { field: 'status', size: 50 } } } }
|
|
73
|
-
|
|
74
|
-
// Group with metric
|
|
75
|
-
{ aggs: {
|
|
76
|
-
by_region: {
|
|
77
|
-
terms: { field: 'region', size: 50 },
|
|
78
|
-
aggs: { total: { sum: { field: 'amount' } } }
|
|
79
|
-
}
|
|
80
|
-
} }
|
|
81
|
-
|
|
82
|
-
// Date histogram
|
|
83
|
-
{ aggs: {
|
|
84
|
-
by_month: {
|
|
85
|
-
date_histogram: { field: 'date', calendar_interval: 'month' },
|
|
86
|
-
aggs: { total: { sum: { field: 'amount' } } }
|
|
87
|
-
}
|
|
88
|
-
} }
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Queries
|
|
92
|
-
|
|
93
|
-
### GET /api/queries-list
|
|
94
|
-
List saved queries.
|
|
95
|
-
|
|
96
|
-
```javascript
|
|
97
|
-
const queries = await fetch('/api/queries-list').then(r => r.json());
|
|
98
|
-
// [{ id, name, description, tags, sharing }, ...]
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
### POST /api/queries/{id}/_execute
|
|
102
|
-
Execute a saved query.
|
|
103
|
-
|
|
104
|
-
```javascript
|
|
105
|
-
const result = await fetch(`/api/queries/${id}/_execute`, {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
headers: { 'Content-Type': 'application/json' },
|
|
108
|
-
body: JSON.stringify({
|
|
109
|
-
parameters: { startDate: '2024-01-01' }
|
|
110
|
-
})
|
|
111
|
-
}).then(r => r.json());
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
## Integrations
|
|
115
|
-
|
|
116
|
-
### GET /api/integrations
|
|
117
|
-
List configured integrations.
|
|
118
|
-
|
|
119
|
-
```javascript
|
|
120
|
-
const result = await fetch('/api/integrations').then(r => r.json());
|
|
121
|
-
// result.items = [{ id, name, slug, type, description }, ...]
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### POST /api/integrations/{id}/request
|
|
125
|
-
Make an authenticated request through an integration.
|
|
126
|
-
|
|
127
|
-
```javascript
|
|
128
|
-
const result = await fetch(`/api/integrations/${slugOrId}/request`, {
|
|
129
|
-
method: 'POST',
|
|
130
|
-
headers: { 'Content-Type': 'application/json' },
|
|
131
|
-
body: JSON.stringify({
|
|
132
|
-
url: '/path/to/endpoint',
|
|
133
|
-
method: 'GET', // GET, POST, PUT, PATCH, DELETE
|
|
134
|
-
params: { key: 'value' }, // Query parameters
|
|
135
|
-
data: { key: 'value' }, // Request body (POST/PUT/PATCH)
|
|
136
|
-
headers: {} // Additional headers
|
|
137
|
-
})
|
|
138
|
-
}).then(r => r.json());
|
|
139
|
-
|
|
140
|
-
// result.status - HTTP status
|
|
141
|
-
// result.data - Response body
|
|
142
|
-
// result.error - true if error status
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
**Salesforce examples:**
|
|
146
|
-
|
|
147
|
-
```javascript
|
|
148
|
-
// SOQL query
|
|
149
|
-
await fetch('/api/integrations/salesforce/request', {
|
|
150
|
-
method: 'POST',
|
|
151
|
-
headers: { 'Content-Type': 'application/json' },
|
|
152
|
-
body: JSON.stringify({
|
|
153
|
-
url: '/data/v59.0/query',
|
|
154
|
-
method: 'GET',
|
|
155
|
-
params: { q: "SELECT Id, Name FROM Account LIMIT 10" }
|
|
156
|
-
})
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Get record
|
|
160
|
-
await fetch('/api/integrations/salesforce/request', {
|
|
161
|
-
method: 'POST',
|
|
162
|
-
headers: { 'Content-Type': 'application/json' },
|
|
163
|
-
body: JSON.stringify({
|
|
164
|
-
url: '/data/v59.0/sobjects/Account/001xxxxxxxxxxxx',
|
|
165
|
-
method: 'GET'
|
|
166
|
-
})
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// Create record
|
|
170
|
-
await fetch('/api/integrations/salesforce/request', {
|
|
171
|
-
method: 'POST',
|
|
172
|
-
headers: { 'Content-Type': 'application/json' },
|
|
173
|
-
body: JSON.stringify({
|
|
174
|
-
url: '/data/v59.0/sobjects/Contact',
|
|
175
|
-
method: 'POST',
|
|
176
|
-
data: { FirstName: 'John', LastName: 'Doe', Email: 'john@example.com' }
|
|
177
|
-
})
|
|
178
|
-
});
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
## Data Access Configuration
|
|
182
|
-
|
|
183
|
-
Create `data-access.yaml` in your project root to declare which APIs your report needs. Without this file, all API access is blocked when published.
|
|
184
|
-
|
|
185
|
-
```yaml
|
|
186
|
-
# data-access.yaml
|
|
187
|
-
|
|
188
|
-
datasets:
|
|
189
|
-
- admin:sales-data
|
|
190
|
-
|
|
191
|
-
queries:
|
|
192
|
-
- admin:summary
|
|
193
|
-
|
|
194
|
-
integrations:
|
|
195
|
-
- salesforce
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### Row-Level Security
|
|
199
|
-
|
|
200
|
-
```yaml
|
|
201
|
-
datasets:
|
|
202
|
-
- id: admin:orders
|
|
203
|
-
filter:
|
|
204
|
-
region: $user.custom.region
|
|
205
|
-
owner: $user.username
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Integration Credentials
|
|
209
|
-
|
|
210
|
-
```yaml
|
|
211
|
-
integrations:
|
|
212
|
-
- id: partner-api
|
|
213
|
-
headers:
|
|
214
|
-
Authorization: Bearer $user.custom.apiToken
|
|
215
|
-
params:
|
|
216
|
-
tenant: $tenant.id
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### Variables
|
|
220
|
-
|
|
221
|
-
- `$user.username`, `$user.email`, `$user.displayName`
|
|
222
|
-
- `$user.custom.xxx` - Custom user fields
|
|
223
|
-
- `$tenant.id`
|
|
224
|
-
- `$report.id`, `$report.name`
|
|
225
|
-
|
|
226
|
-
## Error Handling
|
|
227
|
-
|
|
228
|
-
API errors return:
|
|
229
|
-
```javascript
|
|
230
|
-
{
|
|
231
|
-
statusCode: 400,
|
|
232
|
-
error: 'Bad Request',
|
|
233
|
-
message: 'Description of error'
|
|
234
|
-
}
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Common status codes:
|
|
238
|
-
- `400` - Bad request / validation error
|
|
239
|
-
- `401` - Not authenticated
|
|
240
|
-
- `403` - Not authorized
|
|
241
|
-
- `404` - Not found
|
|
242
|
-
- `502` - Upstream error (integration request failed)
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
# Report Templates
|
|
2
|
-
|
|
3
|
-
## Minimal Starter
|
|
4
|
-
|
|
5
|
-
### index.html
|
|
6
|
-
```html
|
|
7
|
-
<!DOCTYPE html>
|
|
8
|
-
<html lang="en">
|
|
9
|
-
<head>
|
|
10
|
-
<meta charset="UTF-8">
|
|
11
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
12
|
-
<title>Report</title>
|
|
13
|
-
<link rel="stylesheet" href="styles.css">
|
|
14
|
-
</head>
|
|
15
|
-
<body>
|
|
16
|
-
<div id="app">
|
|
17
|
-
<h1>Report Title</h1>
|
|
18
|
-
<div id="content"></div>
|
|
19
|
-
</div>
|
|
20
|
-
<script type="module" src="main.js"></script>
|
|
21
|
-
</body>
|
|
22
|
-
</html>
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
### styles.css
|
|
26
|
-
```css
|
|
27
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
28
|
-
|
|
29
|
-
body {
|
|
30
|
-
font-family: system-ui, sans-serif;
|
|
31
|
-
background: #0f172a;
|
|
32
|
-
color: #f1f5f9;
|
|
33
|
-
padding: 24px;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
#app {
|
|
37
|
-
max-width: 1200px;
|
|
38
|
-
margin: 0 auto;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
h1 {
|
|
42
|
-
font-size: 24px;
|
|
43
|
-
margin-bottom: 24px;
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
### main.js
|
|
48
|
-
```javascript
|
|
49
|
-
window.informerReady = false;
|
|
50
|
-
|
|
51
|
-
async function init() {
|
|
52
|
-
// List available datasets
|
|
53
|
-
const datasets = await fetch('/api/datasets-list').then(r => r.json());
|
|
54
|
-
console.log('Available datasets:', datasets);
|
|
55
|
-
|
|
56
|
-
// Query a dataset
|
|
57
|
-
if (datasets.length > 0) {
|
|
58
|
-
const result = await fetch(`/api/datasets/${datasets[0].id}/_search`, {
|
|
59
|
-
method: 'POST',
|
|
60
|
-
headers: { 'Content-Type': 'application/json' },
|
|
61
|
-
body: JSON.stringify({ query: { match_all: {} }, size: 10 })
|
|
62
|
-
}).then(r => r.json());
|
|
63
|
-
|
|
64
|
-
const records = result.hits.hits.map(h => h._source);
|
|
65
|
-
console.log('Records:', records);
|
|
66
|
-
|
|
67
|
-
// Render your content here
|
|
68
|
-
document.getElementById('content').innerHTML = `
|
|
69
|
-
<p>Found ${result.hits.total} records</p>
|
|
70
|
-
<pre>${JSON.stringify(records, null, 2)}</pre>
|
|
71
|
-
`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
window.informerReady = true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
init();
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Dashboard Layout
|
|
81
|
-
|
|
82
|
-
### index.html
|
|
83
|
-
```html
|
|
84
|
-
<!DOCTYPE html>
|
|
85
|
-
<html lang="en">
|
|
86
|
-
<head>
|
|
87
|
-
<meta charset="UTF-8">
|
|
88
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
89
|
-
<title>Dashboard</title>
|
|
90
|
-
<link rel="stylesheet" href="styles.css">
|
|
91
|
-
</head>
|
|
92
|
-
<body>
|
|
93
|
-
<div class="dashboard">
|
|
94
|
-
<header>
|
|
95
|
-
<h1>Dashboard Title</h1>
|
|
96
|
-
<p class="subtitle">Description of this dashboard</p>
|
|
97
|
-
</header>
|
|
98
|
-
|
|
99
|
-
<div class="metrics">
|
|
100
|
-
<div class="metric-card">
|
|
101
|
-
<span class="metric-value" id="metric1">--</span>
|
|
102
|
-
<span class="metric-label">Metric 1</span>
|
|
103
|
-
</div>
|
|
104
|
-
<div class="metric-card">
|
|
105
|
-
<span class="metric-value" id="metric2">--</span>
|
|
106
|
-
<span class="metric-label">Metric 2</span>
|
|
107
|
-
</div>
|
|
108
|
-
<div class="metric-card">
|
|
109
|
-
<span class="metric-value" id="metric3">--</span>
|
|
110
|
-
<span class="metric-label">Metric 3</span>
|
|
111
|
-
</div>
|
|
112
|
-
<div class="metric-card">
|
|
113
|
-
<span class="metric-value" id="metric4">--</span>
|
|
114
|
-
<span class="metric-label">Metric 4</span>
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
<div class="content">
|
|
119
|
-
<!-- Add your charts, tables, or other visualizations here -->
|
|
120
|
-
<div id="visualization"></div>
|
|
121
|
-
</div>
|
|
122
|
-
</div>
|
|
123
|
-
|
|
124
|
-
<script type="module" src="main.js"></script>
|
|
125
|
-
</body>
|
|
126
|
-
</html>
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
### styles.css
|
|
130
|
-
```css
|
|
131
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
132
|
-
|
|
133
|
-
:root {
|
|
134
|
-
--bg: #0f172a;
|
|
135
|
-
--bg-card: #1e293b;
|
|
136
|
-
--border: #334155;
|
|
137
|
-
--text: #f1f5f9;
|
|
138
|
-
--text-muted: #94a3b8;
|
|
139
|
-
--primary: #6366f1;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
body {
|
|
143
|
-
font-family: system-ui, sans-serif;
|
|
144
|
-
background: var(--bg);
|
|
145
|
-
color: var(--text);
|
|
146
|
-
line-height: 1.5;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
.dashboard {
|
|
150
|
-
max-width: 1400px;
|
|
151
|
-
margin: 0 auto;
|
|
152
|
-
padding: 32px 24px;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
header {
|
|
156
|
-
margin-bottom: 32px;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
header h1 {
|
|
160
|
-
font-size: 28px;
|
|
161
|
-
font-weight: 700;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.subtitle {
|
|
165
|
-
color: var(--text-muted);
|
|
166
|
-
margin-top: 4px;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
.metrics {
|
|
170
|
-
display: grid;
|
|
171
|
-
grid-template-columns: repeat(4, 1fr);
|
|
172
|
-
gap: 16px;
|
|
173
|
-
margin-bottom: 24px;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
.metric-card {
|
|
177
|
-
background: var(--bg-card);
|
|
178
|
-
border: 1px solid var(--border);
|
|
179
|
-
border-radius: 12px;
|
|
180
|
-
padding: 20px;
|
|
181
|
-
text-align: center;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
.metric-value {
|
|
185
|
-
display: block;
|
|
186
|
-
font-size: 32px;
|
|
187
|
-
font-weight: 700;
|
|
188
|
-
color: var(--primary);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
.metric-label {
|
|
192
|
-
display: block;
|
|
193
|
-
font-size: 13px;
|
|
194
|
-
color: var(--text-muted);
|
|
195
|
-
margin-top: 4px;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
.content {
|
|
199
|
-
background: var(--bg-card);
|
|
200
|
-
border: 1px solid var(--border);
|
|
201
|
-
border-radius: 12px;
|
|
202
|
-
padding: 24px;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
@media (max-width: 768px) {
|
|
206
|
-
.metrics { grid-template-columns: repeat(2, 1fr); }
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
@media (max-width: 480px) {
|
|
210
|
-
.metrics { grid-template-columns: 1fr; }
|
|
211
|
-
}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
### main.js
|
|
215
|
-
```javascript
|
|
216
|
-
window.informerReady = false;
|
|
217
|
-
|
|
218
|
-
async function init() {
|
|
219
|
-
try {
|
|
220
|
-
await loadData();
|
|
221
|
-
window.informerReady = true;
|
|
222
|
-
} catch (err) {
|
|
223
|
-
console.error('Failed to load:', err);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function loadData() {
|
|
228
|
-
// Replace with your dataset ID
|
|
229
|
-
const datasetId = 'admin:your-dataset';
|
|
230
|
-
|
|
231
|
-
const result = await fetch(`/api/datasets/${datasetId}/_search`, {
|
|
232
|
-
method: 'POST',
|
|
233
|
-
headers: { 'Content-Type': 'application/json' },
|
|
234
|
-
body: JSON.stringify({
|
|
235
|
-
query: { match_all: {} },
|
|
236
|
-
size: 0,
|
|
237
|
-
aggs: {
|
|
238
|
-
total: { sum: { field: 'amount' } },
|
|
239
|
-
count: { value_count: { field: 'id' } },
|
|
240
|
-
avg: { avg: { field: 'amount' } },
|
|
241
|
-
by_category: {
|
|
242
|
-
terms: { field: 'category', size: 10 },
|
|
243
|
-
aggs: { total: { sum: { field: 'amount' } } }
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
})
|
|
247
|
-
}).then(r => r.json());
|
|
248
|
-
|
|
249
|
-
// Update metrics
|
|
250
|
-
const aggs = result.aggregations;
|
|
251
|
-
document.getElementById('metric1').textContent = formatCurrency(aggs.total.value);
|
|
252
|
-
document.getElementById('metric2').textContent = aggs.count.value.toLocaleString();
|
|
253
|
-
document.getElementById('metric3').textContent = formatCurrency(aggs.avg.value);
|
|
254
|
-
document.getElementById('metric4').textContent = result.hits.total.toLocaleString();
|
|
255
|
-
|
|
256
|
-
// Use aggs.by_category.buckets for visualization
|
|
257
|
-
// Each bucket has: { key: 'Category Name', doc_count: 123, total: { value: 456 } }
|
|
258
|
-
console.log('Category data:', aggs.by_category.buckets);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function formatCurrency(val) {
|
|
262
|
-
return new Intl.NumberFormat('en-US', {
|
|
263
|
-
style: 'currency',
|
|
264
|
-
currency: 'USD',
|
|
265
|
-
minimumFractionDigits: 0
|
|
266
|
-
}).format(val);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
init();
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
## PDF Export Tips
|
|
273
|
-
|
|
274
|
-
The PDF renderer uses **print media** and adds a `.print` class to `<html>`.
|
|
275
|
-
|
|
276
|
-
```css
|
|
277
|
-
/* Standard @media print works */
|
|
278
|
-
@media print {
|
|
279
|
-
body {
|
|
280
|
-
background: white;
|
|
281
|
-
color: black;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
.no-print {
|
|
285
|
-
display: none;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/* Avoid page breaks inside charts/cards */
|
|
289
|
-
.chart-container,
|
|
290
|
-
.metric-card {
|
|
291
|
-
break-inside: avoid;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/* Or use the .print class */
|
|
296
|
-
.print .no-print {
|
|
297
|
-
display: none;
|
|
298
|
-
}
|
|
299
|
-
```
|