@entrinsik/vite-plugin-informer 1.0.4 → 2.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/bin/deploy.js CHANGED
@@ -72,12 +72,12 @@ try {
72
72
  id: savedId
73
73
  });
74
74
 
75
- // Save the report ID back to package.json for future deploys
75
+ // Save the app ID back to package.json for future deploys
76
76
  if (result.id && result.id !== savedId) {
77
77
  if (!pkg.informer) pkg.informer = {};
78
78
  pkg.informer.id = result.id;
79
79
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
80
- console.log(`Saved report ID "${result.id}" to package.json`);
80
+ console.log(`Saved app ID "${result.id}" to package.json`);
81
81
  }
82
82
  } catch (err) {
83
83
  console.error('Deploy failed:', err.message);
package/bin/init.js CHANGED
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFile, writeFile, mkdir, cp, access, readdir } from 'node:fs/promises';
4
- import { resolve, dirname, join, basename } from 'node:path';
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. Copy Claude skill files
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. Open a terminal and run: claude');
295
- console.log(' The informer skill is available in .claude/skills/');
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
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from 'vite';
2
+ export default function informer(): Plugin;
package/package.json CHANGED
@@ -1,12 +1,28 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "1.0.4",
3
+ "version": "2.0.0",
4
4
  "description": "Vite plugin and publish tool for local Magic Report development",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/entrinsik-org/i5.git",
8
+ "directory": "packages/vite-plugin-informer"
9
+ },
10
+ "publishConfig": {
11
+ "registry": "https://docker.entrinsik.com/repository/entNPM/"
12
+ },
13
+ "author": "Entrinsik Inc.",
14
+ "license": "UNLICENSED",
5
15
  "type": "module",
6
16
  "types": "index.d.ts",
7
17
  "main": "./src/index.js",
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ },
8
21
  "exports": {
9
- ".": "./src/index.js",
22
+ ".": {
23
+ "types": "./index.d.ts",
24
+ "default": "./src/index.js"
25
+ },
10
26
  "./deploy": "./src/deploy.js"
11
27
  },
12
28
  "bin": {
@@ -17,16 +33,12 @@
17
33
  "files": [
18
34
  "src",
19
35
  "bin",
20
- "templates"
36
+ "index.d.ts"
21
37
  ],
22
38
  "peerDependencies": {
23
39
  "vite": ">=5.0.0"
24
40
  },
25
41
  "dependencies": {
26
- "dotenv": "^16.4.0"
27
- },
28
- "engines": {
29
- "node": ">=18.0.0"
30
- },
31
- "license": "UNLICENSED"
42
+ "dotenv": "16.6.1"
43
+ }
32
44
  }
package/src/client.js CHANGED
@@ -60,10 +60,11 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
60
60
  }
61
61
 
62
62
  /**
63
- * Upload a file using chunked Flow.js protocol, then assemble into a report's library.
64
- * @param {{ reportId: string, path: string, buffer: Buffer, filename: string }} opts
63
+ * Upload a file using chunked Flow.js protocol, then assemble into an entity's library.
64
+ * @param {{ entityPath?: string, reportId?: string, path: string, buffer: Buffer, filename: string }} opts
65
65
  */
66
- async function uploadChunked({ reportId, path, buffer, filename }) {
66
+ async function uploadChunked({ entityPath, reportId, path, buffer, filename }) {
67
+ const basePath = entityPath || `reports/${reportId}`;
67
68
  const totalChunks = Math.ceil(buffer.length / CHUNK_SIZE);
68
69
  const uploadId = `publish-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
69
70
 
@@ -97,8 +98,8 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
97
98
  }
98
99
  }
99
100
 
100
- // Assemble chunks into a file in the report's library at the specified path
101
- await request('POST', `reports/${reportId}/_upload`, {
101
+ // Assemble chunks into a file in the entity's library at the specified path
102
+ await request('POST', `${basePath}/_upload`, {
102
103
  uploadId,
103
104
  path
104
105
  });
package/src/deploy.js CHANGED
@@ -14,88 +14,112 @@ const CHUNK_THRESHOLD = 512 * 1024; // 512KB
14
14
  const ROOT_CONFIG_FILES = ['data-access.yaml'];
15
15
 
16
16
  /**
17
- * Deploy a built Vite project to Informer as a Magic Report.
17
+ * Deploy a built Vite project to Informer as an App (or legacy Magic Report).
18
+ *
19
+ * Tries the new /api/apps endpoint first. If the server doesn't support it,
20
+ * falls back to the legacy /api/reports endpoint transparently.
18
21
  *
19
22
  * @param {{ baseUrl: string, apiKey?: string, user?: string, pass?: string, distDir: string, name: string, description?: string, icon?: string, id?: string }} opts
20
- * @returns {Promise<{ id: string, url: string }>} The report's natural ID and URL
23
+ * @returns {Promise<{ id: string, url: string }>} The entity's stable UUID and URL
21
24
  */
22
25
  export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, description, icon, id }) {
23
26
  const api = createClient({ baseUrl, apiKey, user, pass });
24
27
 
25
- let report = null;
28
+ let entity = null;
26
29
  let naturalId = null;
30
+ let apiPrefix = 'apps';
27
31
 
28
- // 1. If we have a saved ID, try to look it up
32
+ // 1. Try to find existing entity apps API first, then legacy reports
29
33
  if (id) {
30
- console.log(`Looking up report "${id}"...`);
31
- report = await api.get(`reports/${id}`);
32
- if (report) {
33
- naturalId = report.naturalId || id;
34
- console.log(`Found existing report: ${naturalId}`);
34
+ console.log(`Looking up "${id}"...`);
35
+ entity = await api.get(`apps/${id}`);
36
+ if (entity) {
37
+ apiPrefix = 'apps';
38
+ naturalId = entity.naturalId || id;
39
+ console.log(`Found existing app: ${naturalId}`);
35
40
  } else {
36
- console.log(`Report "${id}" not found, will create new...`);
41
+ entity = await api.get(`reports/${id}`);
42
+ if (entity) {
43
+ apiPrefix = 'reports';
44
+ naturalId = entity.naturalId || id;
45
+ console.log(`Found existing report: ${naturalId} (legacy API)`);
46
+ } else {
47
+ console.log(`"${id}" not found, will create new...`);
48
+ }
37
49
  }
38
50
  }
39
51
 
40
- // 2. Create if not found (or no ID was provided)
41
- if (!report) {
42
- console.log(`Creating new report "${name}"...`);
52
+ // 2. Create if not found try apps API, fall back to reports
53
+ if (!entity) {
43
54
  const payload = {
44
- type: 'magicReport',
45
55
  name: name,
46
56
  description: description || ''
47
57
  };
48
- // If we have a saved ID (UUID), use it for the new report
49
- if (id) {
50
- payload.id = id;
58
+ if (id) payload.id = id;
59
+
60
+ try {
61
+ console.log(`Creating new app "${name}"...`);
62
+ entity = await api.post('apps', { ...payload, type: 'report' });
63
+ apiPrefix = 'apps';
64
+ } catch (e) {
65
+ // If apps endpoint doesn't exist (404), fall back to legacy reports
66
+ if (e.status === 404) {
67
+ console.log('Apps API not available, using legacy reports API...');
68
+ entity = await api.post('reports', { ...payload, type: 'magicReport' });
69
+ apiPrefix = 'reports';
70
+ } else {
71
+ throw e;
72
+ }
51
73
  }
52
- report = await api.post('reports', payload);
53
- if (!report || !report.id) {
54
- throw new Error(`Failed to create report "${name}"`);
74
+
75
+ if (!entity || !entity.id) {
76
+ throw new Error(`Failed to create "${name}"`);
55
77
  }
56
- naturalId = report.naturalId || `${report.ownerId}:${report.slug}`;
57
- console.log(`Created report: ${naturalId}`);
78
+ naturalId = entity.naturalId || `${entity.ownerId}:${entity.slug}`;
79
+ console.log(`Created: ${naturalId}`);
58
80
  }
59
81
 
60
- // 3. Update name/description/icon if provided (for existing reports)
82
+ const entityPath = `${apiPrefix}/${naturalId}`;
83
+
84
+ // 3. Update name/description/icon if provided (for existing entities)
61
85
  const updates = {};
62
- if (name && report.name !== name) updates.name = name;
63
- if (description !== undefined && report.description !== description) updates.description = description;
86
+ if (name && entity.name !== name) updates.name = name;
87
+ if (description !== undefined && entity.description !== description) updates.description = description;
64
88
  if (icon) {
65
- const currentDefn = report.defn || {};
89
+ const currentDefn = entity.defn || {};
66
90
  if (currentDefn.icon !== icon) {
67
91
  updates.defn = { ...currentDefn, icon };
68
92
  }
69
93
  }
70
94
  if (Object.keys(updates).length > 0) {
71
- await api.put(`reports/${naturalId}`, updates);
95
+ await api.put(entityPath, updates);
72
96
  }
73
97
 
74
- // 5. Snapshot via report route
98
+ // 4. Snapshot
75
99
  console.log('Creating snapshot...');
76
- await api.post(`reports/${naturalId}/snapshots`, {
100
+ await api.post(`${entityPath}/snapshots`, {
77
101
  trigger: 'refresh',
78
102
  limit: 10
79
103
  });
80
104
 
81
- // 6. Clear existing files via report route
105
+ // 5. Clear existing files
82
106
  console.log('Clearing existing files...');
83
- const files = await api.get(`reports/${naturalId}/files?start=0&end=10000`);
107
+ const files = await api.get(`${entityPath}/files?start=0&end=10000`);
84
108
  if (files && Array.isArray(files)) {
85
109
  // Delete non-directories first
86
110
  const nonDirs = files.filter(f => !f.directory);
87
- await Promise.all(nonDirs.map(f => api.del(`reports/${naturalId}/files/${f.id}`)));
111
+ await Promise.all(nonDirs.map(f => api.del(`${entityPath}/files/${f.id}`)));
88
112
 
89
113
  // Then delete directories in reverse order (deepest first by path length)
90
114
  const dirs = files
91
115
  .filter(f => f.directory)
92
116
  .sort((a, b) => (b.path || '').length - (a.path || '').length);
93
117
  for (const d of dirs) {
94
- await api.del(`reports/${naturalId}/files/${d.id}`);
118
+ await api.del(`${entityPath}/files/${d.id}`);
95
119
  }
96
120
  }
97
121
 
98
- // 7. Upload dist/ contents via report routes
122
+ // 6. Upload dist/ contents
99
123
  console.log(`Uploading files from ${distDir}...`);
100
124
  const entries = await walkDir(distDir);
101
125
 
@@ -106,7 +130,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
106
130
  if (content.length > CHUNK_THRESHOLD) {
107
131
  // Large file: chunked upload via Flow.js protocol
108
132
  await api.uploadChunked({
109
- reportId: naturalId,
133
+ entityPath,
110
134
  path: relPath,
111
135
  buffer: content,
112
136
  filename: basename(filePath)
@@ -120,12 +144,12 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
120
144
  ? { content: content.toString('utf8'), encoding: 'utf8' }
121
145
  : { content: content.toString('base64'), encoding: 'base64' };
122
146
 
123
- await api.put(`reports/${naturalId}/contents/${relPath}`, payload);
147
+ await api.put(`${entityPath}/contents/${relPath}`, payload);
124
148
  console.log(` ${relPath}`);
125
149
  }
126
150
  }
127
151
 
128
- // 8. Upload config files from project root (e.g., data-access.yaml)
152
+ // 7. Upload config files from project root (e.g., data-access.yaml)
129
153
  const projectRoot = dirname(distDir);
130
154
  let configCount = 0;
131
155
  for (const configFile of ROOT_CONFIG_FILES) {
@@ -133,7 +157,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
133
157
  try {
134
158
  await access(configPath);
135
159
  const content = await readFile(configPath, 'utf8');
136
- await api.put(`reports/${naturalId}/contents/${configFile}`, {
160
+ await api.put(`${entityPath}/contents/${configFile}`, {
137
161
  content,
138
162
  encoding: 'utf8'
139
163
  });
@@ -144,13 +168,15 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
144
168
  }
145
169
  }
146
170
 
147
- // 9. Print report URL and return UUID for saving
171
+ // 8. Print URL and return UUID for saving
148
172
  const totalFiles = entries.length + configCount;
149
- const reportUrl = `${baseUrl.replace(/\/+$/, '')}/reports/r/${naturalId}`;
150
- console.log(`\nPublished ${totalFiles} files to: ${reportUrl}`);
173
+ const base = baseUrl.replace(/\/+$/, '');
174
+ const entityUrl = apiPrefix === 'apps'
175
+ ? `${base}/api/apps/${naturalId}/view`
176
+ : `${base}/reports/r/${naturalId}`;
177
+ console.log(`\nPublished ${totalFiles} files to: ${entityUrl}`);
151
178
 
152
- // Return the stable UUID (not natural ID) for package.json storage
153
- return { id: report.id, url: reportUrl };
179
+ return { id: entity.id, url: entityUrl };
154
180
  }
155
181
 
156
182
  /**
@@ -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
- ```