@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/bin/deploy.js +57 -0
- package/bin/init.js +434 -0
- package/package.json +30 -0
- package/src/client.js +114 -0
- package/src/deploy.js +156 -0
- package/src/index.js +88 -0
- package/templates/skill/SKILL.md +388 -0
- package/templates/skill/references/api-reference.md +242 -0
- package/templates/skill/references/report-templates.md +299 -0
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
|