@entrinsik/vite-plugin-informer 1.0.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/src/deploy.js ADDED
@@ -0,0 +1,156 @@
1
+ import { createClient } from './client.js';
2
+ import { readdir, readFile, stat, access } from 'node:fs/promises';
3
+ import { join, relative, posix, basename, dirname } from 'node:path';
4
+
5
+ const TEXT_EXTENSIONS = new Set([
6
+ '.html', '.css', '.js', '.mjs', '.json', '.svg',
7
+ '.xml', '.txt', '.md', '.csv', '.map', '.yaml', '.yml'
8
+ ]);
9
+
10
+ // Files above this size use chunked upload via Flow.js protocol
11
+ const CHUNK_THRESHOLD = 512 * 1024; // 512KB
12
+
13
+ // Config files to upload from project root (if they exist)
14
+ const ROOT_CONFIG_FILES = ['data-access.yaml'];
15
+
16
+ /**
17
+ * Deploy a built Vite project to Informer as a Magic Report.
18
+ *
19
+ * @param {{ baseUrl: string, apiKey?: string, user?: string, pass?: string, slug: string, distDir: string, name?: string, description?: string }} opts
20
+ */
21
+ export async function deploy({ baseUrl, apiKey, user, pass, slug, distDir, name, description }) {
22
+ const api = createClient({ baseUrl, apiKey, user, pass });
23
+
24
+ // 1. Get current user for natural ID
25
+ const me = await api.get('me');
26
+ const naturalId = `${me.username}:${slug}`;
27
+
28
+ // 2. Lookup report by natural ID
29
+ let report = await api.get(`reports/${naturalId}`);
30
+
31
+ // 3. Create if not found
32
+ if (!report) {
33
+ console.log(`Report "${naturalId}" not found, creating...`);
34
+ await api.post('reports', {
35
+ type: 'magicReport',
36
+ name: name || slug,
37
+ description: description || ''
38
+ });
39
+ // Re-fetch to get the full report
40
+ report = await api.get(`reports/${naturalId}`);
41
+ if (!report) {
42
+ throw new Error(`Failed to create report "${naturalId}"`);
43
+ }
44
+ console.log(`Created report: ${report.id}`);
45
+ }
46
+
47
+ // 4. Update name/description if provided
48
+ const updates = {};
49
+ if (name) updates.name = name;
50
+ if (description !== undefined) updates.description = description;
51
+ if (Object.keys(updates).length > 0) {
52
+ await api.put(`reports/${naturalId}`, updates);
53
+ }
54
+
55
+ // 5. Snapshot via report route
56
+ console.log('Creating snapshot...');
57
+ await api.post(`reports/${naturalId}/snapshots`, {
58
+ trigger: 'refresh',
59
+ limit: 10
60
+ });
61
+
62
+ // 6. Clear existing files via report route
63
+ console.log('Clearing existing files...');
64
+ const files = await api.get(`reports/${naturalId}/files?start=0&end=10000`);
65
+ if (files && Array.isArray(files)) {
66
+ // Delete non-directories first
67
+ const nonDirs = files.filter(f => !f.directory);
68
+ await Promise.all(nonDirs.map(f => api.del(`reports/${naturalId}/files/${f.id}`)));
69
+
70
+ // Then delete directories in reverse order (deepest first by path length)
71
+ const dirs = files
72
+ .filter(f => f.directory)
73
+ .sort((a, b) => (b.path || '').length - (a.path || '').length);
74
+ for (const d of dirs) {
75
+ await api.del(`reports/${naturalId}/files/${d.id}`);
76
+ }
77
+ }
78
+
79
+ // 7. Upload dist/ contents via report routes
80
+ console.log(`Uploading files from ${distDir}...`);
81
+ const entries = await walkDir(distDir);
82
+
83
+ for (const filePath of entries) {
84
+ const relPath = posix.normalize(relative(distDir, filePath).split('\\').join('/'));
85
+ const content = await readFile(filePath);
86
+
87
+ if (content.length > CHUNK_THRESHOLD) {
88
+ // Large file: chunked upload via Flow.js protocol
89
+ await api.uploadChunked({
90
+ reportId: naturalId,
91
+ path: relPath,
92
+ buffer: content,
93
+ filename: basename(filePath)
94
+ });
95
+ console.log(` ${relPath} (${formatSize(content.length)}, chunked)`);
96
+ } else {
97
+ // Small file: direct JSON upload
98
+ const ext = '.' + relPath.split('.').pop();
99
+ const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
100
+ const payload = isText
101
+ ? { content: content.toString('utf8'), encoding: 'utf8' }
102
+ : { content: content.toString('base64'), encoding: 'base64' };
103
+
104
+ await api.put(`reports/${naturalId}/contents/${relPath}`, payload);
105
+ console.log(` ${relPath}`);
106
+ }
107
+ }
108
+
109
+ // 8. Upload config files from project root (e.g., data-access.yaml)
110
+ const projectRoot = dirname(distDir);
111
+ let configCount = 0;
112
+ for (const configFile of ROOT_CONFIG_FILES) {
113
+ const configPath = join(projectRoot, configFile);
114
+ try {
115
+ await access(configPath);
116
+ const content = await readFile(configPath, 'utf8');
117
+ await api.put(`reports/${naturalId}/contents/${configFile}`, {
118
+ content,
119
+ encoding: 'utf8'
120
+ });
121
+ console.log(` ${configFile} (from project root)`);
122
+ configCount++;
123
+ } catch {
124
+ // File doesn't exist, skip
125
+ }
126
+ }
127
+
128
+ // 9. Print report URL
129
+ const totalFiles = entries.length + configCount;
130
+ const reportUrl = `${baseUrl.replace(/\/+$/, '')}/reports/r/${naturalId}`;
131
+ console.log(`\nPublished ${totalFiles} files to: ${reportUrl}`);
132
+ }
133
+
134
+ /**
135
+ * Recursively walk a directory, returning file paths (not directories).
136
+ */
137
+ async function walkDir(dir) {
138
+ const results = [];
139
+ const items = await readdir(dir);
140
+ for (const item of items) {
141
+ const full = join(dir, item);
142
+ const s = await stat(full);
143
+ if (s.isDirectory()) {
144
+ results.push(...await walkDir(full));
145
+ } else {
146
+ results.push(full);
147
+ }
148
+ }
149
+ return results;
150
+ }
151
+
152
+ function formatSize(bytes) {
153
+ if (bytes < 1024) return `${bytes} B`;
154
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
155
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
156
+ }
package/src/index.js ADDED
@@ -0,0 +1,88 @@
1
+ import dotenv from 'dotenv';
2
+
3
+ /**
4
+ * Vite plugin for local Magic Report development.
5
+ *
6
+ * - Proxies /api requests to the Informer server with Basic auth
7
+ * - Injects window.__INFORMER__ context mock in dev mode
8
+ * - Sets base to './' so built assets use relative paths
9
+ *
10
+ * @param {{ mock?: object }} options
11
+ * @returns {import('vite').Plugin}
12
+ */
13
+ export default function informer(options = {}) {
14
+ let isDev = false;
15
+
16
+ return {
17
+ name: 'vite-plugin-informer',
18
+
19
+ config(_, { command }) {
20
+ dotenv.config();
21
+
22
+ isDev = command === 'serve';
23
+
24
+ const cfg = {
25
+ base: './'
26
+ };
27
+
28
+ if (isDev) {
29
+ const baseUrl = process.env.INFORMER_URL;
30
+ const apiKey = process.env.INFORMER_API_KEY;
31
+ const user = process.env.INFORMER_USER;
32
+ const pass = process.env.INFORMER_PASS;
33
+
34
+ if (baseUrl) {
35
+ const auth = apiKey
36
+ ? 'Bearer ' + apiKey
37
+ : 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
38
+
39
+ cfg.server = {
40
+ proxy: {
41
+ '/api': {
42
+ target: baseUrl,
43
+ changeOrigin: true,
44
+ headers: {
45
+ Authorization: auth
46
+ }
47
+ }
48
+ }
49
+ };
50
+ }
51
+ }
52
+
53
+ return cfg;
54
+ },
55
+
56
+ transformIndexHtml: {
57
+ order: 'pre',
58
+ handler(html) {
59
+ if (!isDev) return html;
60
+
61
+ const mock = {
62
+ report: {
63
+ id: 'dev-local',
64
+ name: 'Local Development'
65
+ },
66
+ ...options.mock
67
+ };
68
+
69
+ const script = `<script>
70
+ (function() {
71
+ 'use strict';
72
+ window.__INFORMER__ = ${JSON.stringify(mock)};
73
+ })();
74
+ </script>`;
75
+
76
+ // Insert after <head> tag, matching server behavior
77
+ const headIdx = html.indexOf('<head>');
78
+ if (headIdx !== -1) {
79
+ const insertPos = headIdx + '<head>'.length;
80
+ return html.slice(0, insertPos) + '\n' + script + '\n' + html.slice(insertPos);
81
+ }
82
+
83
+ // Fallback: prepend
84
+ return script + '\n' + html;
85
+ }
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,388 @@
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
+ ## Discovering Resources
51
+
52
+ Once `.env` is configured, Claude can query the Informer API directly to help you find available resources. Ask Claude to look up:
53
+
54
+ - **Integrations**: `curl -u $USER:$PASS "$INFORMER_URL/api/integrations"` - Find integration slugs for QuickBooks, Salesforce, etc.
55
+ - **Datasets**: `curl -u $USER:$PASS "$INFORMER_URL/api/datasets-list"` - Find dataset IDs and field names
56
+ - **Queries**: `curl -u $USER:$PASS "$INFORMER_URL/api/queries-list"` - Find saved query IDs
57
+ - **Datasources**: `curl -u $USER:$PASS "$INFORMER_URL/api/datasources"` - Find SQL datasource IDs
58
+
59
+ 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".
60
+
61
+ ## Key APIs
62
+
63
+ All endpoints are relative to `/api`. In dev mode, the Vite proxy handles auth.
64
+
65
+ ### List Datasets
66
+
67
+ ```javascript
68
+ const response = await fetch('/api/datasets-list');
69
+ const datasets = await response.json();
70
+ // Returns: [{ id, name, description, records, size, ... }, ...]
71
+ ```
72
+
73
+ Use this to discover available datasets. Each dataset has:
74
+ - `id` - UUID or natural ID like `admin:sales-data`
75
+ - `name` - Display name
76
+ - `records` - Approximate record count
77
+
78
+ ### Search Dataset (Elasticsearch)
79
+
80
+ ```javascript
81
+ const response = await fetch(`/api/datasets/${datasetId}/_search`, {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({
85
+ query: { match_all: {} },
86
+ size: 100,
87
+ from: 0,
88
+ _source: ['field1', 'field2'], // Optional: limit fields returned
89
+ sort: [{ field1: 'desc' }], // Optional: sort order
90
+ aggs: { // Optional: aggregations
91
+ total: { sum: { field: 'amount' } }
92
+ }
93
+ })
94
+ });
95
+ const result = await response.json();
96
+
97
+ // Response structure:
98
+ // result.hits.total - total matching records
99
+ // result.hits.hits - array of { _source: { field1, field2, ... } }
100
+ // result.aggregations - aggregation results (if requested)
101
+ ```
102
+
103
+ **Common query patterns:**
104
+
105
+ ```javascript
106
+ // Filter by exact value
107
+ { query: { bool: { filter: [{ term: { status: 'active' } }] } } }
108
+
109
+ // Filter by range
110
+ { query: { bool: { filter: [{ range: { amount: { gte: 1000 } } }] } } }
111
+
112
+ // Date range
113
+ { query: { bool: { filter: [{ range: { date: { gte: '2024-01-01', lte: '2024-12-31' } } }] } } }
114
+
115
+ // Multiple filters (AND)
116
+ { query: { bool: { filter: [
117
+ { term: { region: 'North' } },
118
+ { range: { amount: { gte: 1000 } } }
119
+ ] } } }
120
+ ```
121
+
122
+ **Common aggregations:**
123
+
124
+ ```javascript
125
+ // Sum, avg, min, max
126
+ { aggs: { total: { sum: { field: 'amount' } } } }
127
+
128
+ // Group by field
129
+ { aggs: { by_region: { terms: { field: 'region', size: 50 } } } }
130
+
131
+ // Group with nested metric
132
+ { aggs: {
133
+ by_region: {
134
+ terms: { field: 'region', size: 50 },
135
+ aggs: { total: { sum: { field: 'amount' } } }
136
+ }
137
+ } }
138
+
139
+ // Date histogram
140
+ { aggs: {
141
+ by_month: {
142
+ date_histogram: { field: 'date', calendar_interval: 'month' },
143
+ aggs: { total: { sum: { field: 'amount' } } }
144
+ }
145
+ } }
146
+ ```
147
+
148
+ ### List Queries
149
+
150
+ ```javascript
151
+ const response = await fetch('/api/queries-list');
152
+ const queries = await response.json();
153
+ // Returns: [{ id, name, description, ... }, ...]
154
+ ```
155
+
156
+ ### Execute Query
157
+
158
+ ```javascript
159
+ const response = await fetch(`/api/queries/${queryId}/_execute`, {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({
163
+ parameters: { param1: 'value1' } // Optional query parameters
164
+ })
165
+ });
166
+ const result = await response.json();
167
+ ```
168
+
169
+ ### List Integrations
170
+
171
+ ```javascript
172
+ const response = await fetch('/api/integrations');
173
+ const result = await response.json();
174
+ // result.items = [{ id, name, slug, type, ... }, ...]
175
+ ```
176
+
177
+ Integrations are authenticated connections to external APIs (Salesforce, REST APIs, etc.).
178
+
179
+ ### Make Integration Request
180
+
181
+ ```javascript
182
+ const response = await fetch(`/api/integrations/${slugOrId}/request`, {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({
186
+ url: '/data/v59.0/query', // Path relative to integration's base URL
187
+ method: 'GET', // HTTP method
188
+ params: { q: 'SELECT Id FROM Account' }, // Query params
189
+ data: { /* body for POST/PUT */ }, // Request body
190
+ headers: { /* extra headers */ } // Additional headers
191
+ })
192
+ });
193
+ const result = await response.json();
194
+
195
+ // Response structure:
196
+ // result.status - HTTP status code
197
+ // result.data - response body from the external API
198
+ // result.error - true if upstream returned an error status
199
+ ```
200
+
201
+ **Salesforce example:**
202
+ ```javascript
203
+ const response = await fetch('/api/integrations/salesforce/request', {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({
207
+ url: '/data/v59.0/query',
208
+ method: 'GET',
209
+ params: {
210
+ q: "SELECT Id, Name, Amount FROM Opportunity WHERE StageName = 'Closed Won'"
211
+ }
212
+ })
213
+ });
214
+ const result = await response.json();
215
+ const records = result.data.records;
216
+ ```
217
+
218
+ ## Data Access Configuration
219
+
220
+ 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).
221
+
222
+ **Important:** Without this file, all API access is blocked when the report runs in Informer.
223
+
224
+ ### Basic Example
225
+
226
+ ```yaml
227
+ # data-access.yaml
228
+
229
+ datasets:
230
+ - admin:sales-data
231
+ - admin:customers
232
+
233
+ queries:
234
+ - admin:monthly-summary
235
+
236
+ integrations:
237
+ - salesforce
238
+ ```
239
+
240
+ ### With Row-Level Security
241
+
242
+ Restrict data based on the viewing user's profile:
243
+
244
+ ```yaml
245
+ datasets:
246
+ # Users only see their region's data
247
+ - id: admin:orders
248
+ filter:
249
+ region: $user.custom.region
250
+
251
+ # Users only see their own records
252
+ - id: admin:sales
253
+ filter:
254
+ sales_rep: $user.username
255
+ ```
256
+
257
+ ### Integration with Credentials
258
+
259
+ Pass user-specific credentials to external APIs:
260
+
261
+ ```yaml
262
+ integrations:
263
+ - id: partner-api
264
+ headers:
265
+ Authorization: Bearer $user.custom.partnerToken
266
+ params:
267
+ client_id: $tenant.id
268
+ ```
269
+
270
+ ### Available Variables
271
+
272
+ | Variable | Description |
273
+ |----------|-------------|
274
+ | `$user.username` | Login name |
275
+ | `$user.email` | Email address |
276
+ | `$user.displayName` | Full name |
277
+ | `$user.custom.xxx` | Custom user field |
278
+ | `$tenant.id` | Tenant ID |
279
+ | `$report.id` | Report UUID |
280
+
281
+ ### Resource Types
282
+
283
+ | Type | API Access Granted |
284
+ |------|-------------------|
285
+ | `datasets` | `_search`, `fields` |
286
+ | `queries` | `_execute` |
287
+ | `datasources` | `_query` |
288
+ | `integrations` | `request` |
289
+ | `libraries` | `contents/*` |
290
+
291
+ For edge cases, you can also whitelist raw API paths:
292
+
293
+ ```yaml
294
+ apis:
295
+ - POST /api/custom/endpoint
296
+ ```
297
+
298
+ ## Report Context
299
+
300
+ When running inside Informer (not dev mode), the report receives context:
301
+
302
+ ```javascript
303
+ const reportId = window.__INFORMER__?.report?.id;
304
+ const reportName = window.__INFORMER__?.report?.name;
305
+ ```
306
+
307
+ In dev mode, the Vite plugin mocks this with placeholder values.
308
+
309
+ ## PDF Export
310
+
311
+ Reports can be exported to PDF via `POST /api/reports/{id}/_print`.
312
+
313
+ ### How it works
314
+
315
+ 1. Informer opens your report in a headless browser (Puppeteer)
316
+ 2. Waits for network requests to complete
317
+ 3. Waits for `window.informerReady` to become `true`
318
+ 4. Adds `.print` class to `<html>`
319
+ 5. Captures the page as PDF using print media
320
+
321
+ ### Signal when ready
322
+
323
+ Set `window.informerReady` to signal when your report is fully rendered:
324
+
325
+ ```javascript
326
+ // Start of app
327
+ window.informerReady = false;
328
+
329
+ // After all charts/content rendered
330
+ window.informerReady = true;
331
+ ```
332
+
333
+ ### Rendering details
334
+
335
+ - **Print media is used** - Standard `@media print` CSS rules apply
336
+ - **`.print` class added** - Informer adds a `.print` class to `<html>` for additional targeting
337
+ - **Viewport is 1200px** by default (configurable via `viewportWidth` option)
338
+ - **Box shadows are removed** - They render as grey boxes in PDFs
339
+ - **Colors are preserved** - `print-color-adjust: exact` is applied automatically
340
+
341
+ ### Print CSS
342
+
343
+ Use standard `@media print` rules or the `.print` class:
344
+
345
+ ```css
346
+ /* Standard print media query */
347
+ @media print {
348
+ body {
349
+ background: white;
350
+ color: black;
351
+ }
352
+
353
+ .no-print {
354
+ display: none;
355
+ }
356
+ }
357
+
358
+ /* Or use the .print class (added by Informer) */
359
+ .print .no-print {
360
+ display: none;
361
+ }
362
+
363
+ /* Avoid page breaks inside elements */
364
+ .chart-container {
365
+ break-inside: avoid;
366
+ }
367
+ ```
368
+
369
+ ### Print API options
370
+
371
+ ```javascript
372
+ await fetch(`/api/reports/${reportId}/_print`, {
373
+ method: 'POST',
374
+ headers: { 'Content-Type': 'application/json' },
375
+ body: JSON.stringify({
376
+ format: 'Letter', // Letter, Legal, Tabloid, A3, A4, A5
377
+ landscape: false,
378
+ viewportWidth: 1200, // 400-2400, affects responsive layouts
379
+ waitForReady: true, // Wait for window.informerReady
380
+ save: false // true = save to downloads, false = return PDF
381
+ })
382
+ });
383
+ ```
384
+
385
+ ## Reference Files
386
+
387
+ - `references/api-reference.md` - Detailed API documentation
388
+ - `references/report-templates.md` - HTML/CSS/JS starter templates