feat(cli-serve): improve remote configs (#245)

This commit is contained in:
Miralem Drek
2019-12-18 19:03:45 +01:00
committed by GitHub
parent 5b4ea46725
commit a08cf8ccd8
15 changed files with 358 additions and 186 deletions

View File

@@ -2,7 +2,7 @@ describe('sn', () => {
const content = '.nebulajs-sn'; const content = '.nebulajs-sn';
it('should say hello', async () => { it('should say hello', async () => {
const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf'); const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf');
await page.goto(`${process.env.BASE_URL}/render/app/${app}`); await page.goto(`${process.env.BASE_URL}/render/?app=${app}`);
await page.waitForFunction(`!!document.querySelector('${content}')`); await page.waitForFunction(`!!document.querySelector('${content}')`);
const text = await page.$eval(content, el => el.textContent); const text = await page.$eval(content, el => el.textContent);
expect(text).to.equal('Hello!'); expect(text).to.equal('Hello!');

View File

@@ -3,7 +3,7 @@ describe('interaction', () => {
it('should select two bars', async () => { it('should select two bars', async () => {
const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf'); const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf');
await page.goto( await page.goto(
`${process.env.BASE_URL}/render/app/${app}?cols=Alpha,=5+avg(Expression1)&&permissions=interact,select` `${process.env.BASE_URL}/render/?app=${app}&cols=Alpha,=5+avg(Expression1)&permissions=interact,select`
); );
await page.waitForSelector(content, { visible: true }); await page.waitForSelector(content, { visible: true });

View File

@@ -90,7 +90,11 @@ module.exports = async argv => {
let snPath; let snPath;
let snName; let snName;
let watcher; let watcher;
if (serveConfig.entry) { let snUrl;
if (/^https?/.test(serveConfig.entry)) {
snName = 'remote';
snUrl = serveConfig.entry;
} else if (serveConfig.entry) {
snPath = path.resolve(context, serveConfig.entry); snPath = path.resolve(context, serveConfig.entry);
const parsed = path.parse(snPath); const parsed = path.parse(snPath);
snName = parsed.name; snName = parsed.name;
@@ -111,7 +115,7 @@ module.exports = async argv => {
} }
} }
const ww = await initiateWatch({ snPath, snName: serveConfig.type || snName, host }); const ww = snUrl ? null : await initiateWatch({ snPath, snName: serveConfig.type || snName, host });
const server = await webpackServe({ const server = await webpackServe({
host, host,
@@ -119,7 +123,7 @@ module.exports = async argv => {
enigmaConfig, enigmaConfig,
webIntegrationId: serveConfig.webIntegrationId, webIntegrationId: serveConfig.webIntegrationId,
snName: serveConfig.type || snName, snName: serveConfig.type || snName,
snPath, snUrl,
dev: process.env.MONO === 'true', dev: process.env.MONO === 'true',
open: serveConfig.open !== false, open: serveConfig.open !== false,
entryWatcher: ww, entryWatcher: ww,

View File

@@ -15,6 +15,7 @@ module.exports = async ({
enigmaConfig, enigmaConfig,
webIntegrationId, webIntegrationId,
snName, snName,
snUrl,
dev = false, dev = false,
open = true, open = true,
entryWatcher, entryWatcher,
@@ -74,7 +75,9 @@ module.exports = async ({
before(app) { before(app) {
app.use(snapshotRoute, snapRouter); app.use(snapshotRoute, snapRouter);
entryWatcher.addRoutes(app); if (entryWatcher) {
entryWatcher.addRoutes(app);
}
app.get('/themes', (req, res) => { app.get('/themes', (req, res) => {
const arr = themes.map(theme => theme.key); const arr = themes.map(theme => theme.key);
@@ -96,9 +99,10 @@ module.exports = async ({
webIntegrationId, webIntegrationId,
supernova: { supernova: {
name: snName, name: snName,
url: snUrl,
}, },
sock: { sock: {
port: entryWatcher.port, port: entryWatcher && entryWatcher.port,
}, },
themes: themes.map(theme => theme.key), themes: themes.map(theme => theme.key),
}); });

View File

@@ -246,7 +246,12 @@ export default function App({ app, info }) {
<Toolbar variant="dense" style={{ background: theme.palette.background.paper }}> <Toolbar variant="dense" style={{ background: theme.palette.background.paper }}>
<Grid container> <Grid container>
<Grid item container alignItems="center" style={{ width: 'auto' }}> <Grid item container alignItems="center" style={{ width: 'auto' }}>
<Button variant="contained" href={window.location.origin}> <Button
variant="contained"
href={`${window.location.origin}?engine_url=${info.engineUrl || ''}${
info.webIntegrationId ? `&web-integration-id=${info.webIntegrationId}` : ''
}`}
>
{/* <IconButton style={{ padding: '0px' }}> {/* <IconButton style={{ padding: '0px' }}>
<ChevronLeft style={{ verticalAlign: 'middle' }} /> <ChevronLeft style={{ verticalAlign: 'middle' }} />
</IconButton> */} </IconButton> */}

View File

@@ -121,7 +121,9 @@ export default function({ id, expandable, minHeight }) {
title="Open in single render view" title="Open in single render view"
href={ href={
model model
? `${document.location.href.replace(/\/dev\//, '/render/')}?object=${ ? `${document.location.href.replace(/\/dev\//, '/render/')}${
window.location.search ? '&' : '?'
}object=${
model.id model.id
}&permissions=passive,interact,select&theme=${currentThemeName}&language=${language}` }&permissions=passive,interact,select&theme=${currentThemeName}&language=${language}`
: '' : ''

View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { createTheme, ThemeProvider } from '@nebula.js/ui/theme';
import Box from '@material-ui/core/Box';
import OutlinedInput from '@material-ui/core/OutlinedInput';
import Paper from '@material-ui/core/Paper';
import Grid from '@material-ui/core/Grid';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Typography from '@material-ui/core/Typography';
import CircularProgress from '@material-ui/core/CircularProgress';
import { info as connectionInfo, connect } from '../connect';
const theme = createTheme('light');
function URLInput({ info }) {
const onKeyDown = e => {
switch (e.key) {
case 'Enter':
window.location.href = `${window.location.origin}?engine_url=${e.target.value}`;
break;
case 'Escape':
break;
default:
break;
}
};
return (
<>
<Box style={{ height: '30vh' }} />
<Box boxShadow={24}>
<OutlinedInput
autoFocus
fullWidth
placeholder="Engine WebSocket URL"
onKeyDown={onKeyDown}
defaultValue={info.engineUrl}
/>
</Box>
</>
);
}
function AppList({ info }) {
const [items, setItems] = useState();
const [err, setError] = useState();
useEffect(() => {
connect()
.then(g => g.getDocList().then(setItems))
.catch(e => {
const oops = {
message: 'Something went wrong, check the console',
hints: [],
};
if (e.target instanceof WebSocket) {
oops.message = `Connection failed to ${info.engineUrl}`;
if (!info.webIntegrationId) {
oops.hints.push('If you are connecting to QCS/QSEoK, make sure to provide a web-integration-id');
}
setError(oops);
return;
}
setError(oops);
console.error(e);
});
}, []);
if (err) {
return (
<Paper elevation={24}>
<Box p={2}>
<Typography variant="h6" color="error" gutterBottom>
Error
</Typography>
<Typography gutterBottom>{err.message} </Typography>
{err.hints.map(hint => (
<Typography key={hint} variant="body2" color="textSecondary">
{hint}
</Typography>
))}
</Box>
</Paper>
);
}
if (!items) {
return (
<Grid container align="center" direction="column" spacing={2}>
<Grid item>
<CircularProgress size={48} />
</Grid>
<Grid item>
<Typography>Connecting</Typography>
</Grid>
</Grid>
);
}
return (
<Paper elevation={24}>
{items.length ? (
<List>
{items.map(li => (
<ListItem
button
key={li.qDocId}
component="a"
href={`/dev/${window.location.search.replace(
info.engineUrl,
`${info.engineUrl}/app/${encodeURIComponent(li.qDocId)}`
)}`}
>
<ListItemText primary={li.qTitle} secondary={li.qDocId} />
</ListItem>
))}
</List>
) : (
<Box p={2}>
<Typography component="span">No apps found</Typography>
</Box>
)}
</Paper>
);
}
export default function Hub() {
const [n, setInfo] = useState();
useEffect(() => {
connectionInfo.then(setInfo);
}, []);
if (!n) {
return null;
}
if (n.enigma.appId) {
window.location.href = `/dev/${window.location.search}`;
}
return (
<ThemeProvider theme={theme}>
<Box p={[2, 4]}>
<Grid container justify="center">
<Grid item xs style={{ maxWidth: '800px' }}>
{!n.invalid && n.engineUrl ? <AppList info={n} /> : <URLInput info={n} />}
</Grid>
</Grid>
</Box>
</ThemeProvider>
);
}

View File

@@ -2,15 +2,8 @@ import enigma from 'enigma.js';
import qixSchema from 'enigma.js/schemas/12.34.11.json'; import qixSchema from 'enigma.js/schemas/12.34.11.json';
import SenseUtilities from 'enigma.js/sense-utilities'; import SenseUtilities from 'enigma.js/sense-utilities';
import { requireFrom } from 'd3-require';
const params = (() => { const params = (() => {
const opts = {}; const opts = {};
const { pathname } = window.location;
const am = pathname.match(/\/app\/([^/?&]+)/);
if (am) {
opts.app = decodeURIComponent(am[1]);
}
window.location.search window.location.search
.substring(1) .substring(1)
.split('&') .split('&')
@@ -27,96 +20,112 @@ const params = (() => {
return opts; return opts;
})(); })();
const getModule = name => requireFrom(async n => `/pkg/${encodeURIComponent(n)}`)(name); // Qlik Core: ws://<host>:<port>/app/<data-folder>/<app-name>
// QCS: wss://<tenant-url>.<region>.qlikcloud.com/app/<app-GUID>
// QSEoK: wss://<host>/app/<app-GUID>
// QSEoW: wss://<host>/<virtual-proxy-prefix>/app/<app-GUID>
const RX = /(wss?):\/\/([^/:?&]+)(?::(\d+))?/;
const parseEngineURL = url => {
const m = RX.exec(url);
const hotListeners = {}; if (!m) {
return {
const lightItUp = name => { engineUrl: url,
if (!hotListeners[name]) { invalid: true,
return; };
}
hotListeners[name].forEach(fn => fn());
};
const onHotChange = (name, fn) => {
if (!hotListeners[name]) {
hotListeners[name] = [];
} }
hotListeners[name].push(fn); let appId;
if (window[name]) { let engineUrl = url;
fn(); let appUrl;
const rxApp = /\/app\/([^?&#:]+)/.exec(url);
if (rxApp) {
[, appId] = rxApp;
engineUrl = url.substring(0, rxApp.index);
appUrl = url;
} }
return () => {
// removeListener return {
const idx = hotListeners[name].indexOf(fn); enigma: {
hotListeners[name].splice(idx, 1); secure: m[1] === 'wss',
host: m[2],
port: m[3] || undefined,
appId,
},
engineUrl,
appUrl,
}; };
}; };
window.onHotChange = onHotChange; const connectionInfo = fetch('/info')
const initiateWatch = info => {
const ws = new WebSocket(`ws://localhost:${info.sock.port}`);
const update = () => {
getModule(info.supernova.name).then(mo => {
window[info.supernova.name] = mo;
lightItUp(info.supernova.name);
});
};
ws.onmessage = () => {
update();
};
update();
};
const requestInfo = fetch('/info')
.then(response => response.json()) .then(response => response.json())
.then(async info => { .then(async n => {
initiateWatch(info); let info = n;
const { webIntegrationId } = info; if (params.engine_url) {
const rootPath = `${info.enigma.secure ? 'https' : 'http'}://${info.enigma.host}`; info = {
let headers = {}; ...info,
if (webIntegrationId) { ...parseEngineURL(params.engine_url),
const response = await fetch(`${rootPath}/api/v1/csrf-token`, { };
credentials: 'include', } else if (params.app) {
headers: { 'qlik-web-integration-id': webIntegrationId }, info = {
}); ...info,
if (response.status === 401) { enigma: {
const loginUrl = new URL(`${rootPath}/login`); ...info.enigma,
loginUrl.searchParams.append('returnto', window.location.href); appId: params.app,
loginUrl.searchParams.append('qlik-web-integration-id', webIntegrationId); },
window.location.href = loginUrl;
return undefined;
}
const csrfToken = new Map(response.headers).get('qlik-csrf-token');
headers = {
'qlik-web-integration-id': webIntegrationId,
'qlik-csrf-token': csrfToken,
}; };
} }
if (params['web-integration-id']) {
info.webIntegrationId = params['web-integration-id'];
}
if (info.invalid) {
return info;
}
const rootPath = `${info.enigma.secure ? 'https' : 'http'}://${info.enigma.host}`;
return { return {
...info, ...info,
rootPath, rootPath,
headers,
}; };
}); });
let headers;
const getHeaders = async ({ webIntegrationId, rootPath }) => {
const response = await fetch(`${rootPath}/api/v1/csrf-token`, {
credentials: 'include',
headers: { 'qlik-web-integration-id': webIntegrationId },
});
if (response.status === 401) {
const loginUrl = new URL(`${rootPath}/login`);
loginUrl.searchParams.append('returnto', window.location.href);
loginUrl.searchParams.append('qlik-web-integration-id', webIntegrationId);
window.location.href = loginUrl;
return undefined;
}
const csrfToken = new Map(response.headers).get('qlik-csrf-token');
headers = {
'qlik-web-integration-id': webIntegrationId,
'qlik-csrf-token': csrfToken,
};
return headers;
};
const defaultConfig = { const defaultConfig = {
host: window.location.hostname || 'localhost',
port: 9076,
secure: false, secure: false,
}; };
let connection; let connection;
const connect = () => { const connect = () => {
if (!connection) { if (!connection) {
connection = requestInfo.then(async info => { connection = connectionInfo.then(async info => {
const { webIntegrationId, rootPath, headers } = info; const { webIntegrationId, rootPath } = info;
if (webIntegrationId) { if (webIntegrationId) {
if (!headers) {
headers = await getHeaders(info);
}
return { return {
getDocList: async () => { getDocList: async () => {
const { data = [] } = await ( const { data = [] } = await (
@@ -149,10 +158,12 @@ const connect = () => {
}; };
const openApp = id => const openApp = id =>
requestInfo.then(async info => { connectionInfo.then(async info => {
const { webIntegrationId, headers } = info;
let urlParams = {}; let urlParams = {};
if (webIntegrationId) { if (info.webIntegrationId) {
if (!headers) {
headers = await getHeaders(info);
}
urlParams = { urlParams = {
...headers, ...headers,
}; };
@@ -172,4 +183,4 @@ const openApp = id =>
.then(global => global.openDoc(id)); .then(global => global.openDoc(id));
}); });
export { connect, openApp, params, requestInfo as info }; export { connect, openApp, params, connectionInfo as info };

View File

@@ -3,14 +3,15 @@ import ReactDOM from 'react-dom';
import App from './components/App'; import App from './components/App';
import { openApp, params, info } from './connect'; import { openApp, info } from './connect';
import initiateWatch from './hot';
if (!params.app) { info.then($ => {
location.href = location.origin; //eslint-disable-line if (!$.enigma.appId) {
} window.location.href = `/${window.location.search}`;
}
info.then($ => initiateWatch($);
openApp(params.app).then(app => { return openApp($.enigma.appId).then(app => {
ReactDOM.render(<App app={app} info={$} />, document.querySelector('#app')); ReactDOM.render(<App app={app} info={$} />, document.querySelector('#app'));
}) });
); });

View File

@@ -1,73 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Nebula hub</title> <title>Nebula hub</title>
<style> <style>
html, body { html,
height: 100%; body {
margin: 0; height: 100%;
padding: 0; margin: 0;
overflow: hidden; padding: 0;
} overflow: hidden;
}
body { body {
background: linear-gradient(110deg, #91298C 0%, #45B3B2 100%); background: linear-gradient(110deg, #91298c 0%, #45b3b2 100%);
color: #404040; color: #404040;
font: normal 14px/16px "Source Sans Pro", Arial, sans-serif; font: normal 14px/16px 'Source Sans Pro', Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
</style>
#apps { </head>
background-color: white; <body>
max-width: 400px; <div id="hub"></div>
width: 80%; </body>
margin: 0 auto;
margin-top: 32px;
border-radius: 4px;
box-shadow: 0 32px 32px -16px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
max-height: calc(100% - 64px);
}
.title {
padding: 16px;
font-size: 1.2em;
font-weight: bold;
}
#apps ul {
list-style: none;
padding: 0;
overflow-y: auto;
}
#apps li {
border-top: 1px solid #eee;
}
#apps li a {
padding: 16px;
display: block;
text-decoration: none;
color: #666;
}
#apps li a[href]:hover {
padding: 16px;
background: #eee;
}
</style>
</head>
<body>
<div id="apps">
<div class="title">Apps</div>
<ul>
<li><a>Loading...</a></li>
</ul>
</div>
</body>
</html> </html>

View File

@@ -1,18 +0,0 @@
import { connect } from './connect';
const ul = document.querySelector('#apps ul');
connect().then(qix => {
qix.getDocList().then(list => {
const items = list
.map(
doc => `
<li>
<a href="/dev/app/${encodeURIComponent(doc.qDocId)}">${doc.qTitle}</a>
</li>`
)
.join('');
ul.innerHTML = items;
});
});

View File

@@ -0,0 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Hub from './components/Hub';
ReactDOM.render(<Hub />, document.querySelector('#hub'));

View File

@@ -3,6 +3,7 @@ import snapshooter from '@nebula.js/snapshooter/dist/renderer';
import { openApp, params, info as serverInfo } from './connect'; import { openApp, params, info as serverInfo } from './connect';
import runFixture from './run-fixture'; import runFixture from './run-fixture';
import initiateWatch from './hot';
const nuke = async ({ app, supernova: { name }, themes, theme, language }) => { const nuke = async ({ app, supernova: { name }, themes, theme, language }) => {
const nuked = nucleus.configured({ const nuked = nucleus.configured({
@@ -29,11 +30,12 @@ const nuke = async ({ app, supernova: { name }, themes, theme, language }) => {
}; };
async function renderWithEngine() { async function renderWithEngine() {
if (!params.app) { const info = await serverInfo;
initiateWatch(info);
if (!info.enigma.appId) {
location.href = location.origin; //eslint-disable-line location.href = location.origin; //eslint-disable-line
} }
const info = await serverInfo; const app = await openApp(info.enigma.appId);
const app = await openApp(params.app);
const nebbie = await nuke({ app, ...info, theme: params.theme, language: params.language }); const nebbie = await nuke({ app, ...info, theme: params.theme, language: params.language });
const element = document.querySelector('#chart-container'); const element = document.querySelector('#chart-container');
const vizCfg = { const vizCfg = {
@@ -67,7 +69,9 @@ async function renderWithEngine() {
} }
async function renderSnapshot() { async function renderSnapshot() {
const { themes, supernova } = await serverInfo; const info = await serverInfo;
const { themes, supernova } = info;
initiateWatch(info);
const element = document.querySelector('#chart-container'); const element = document.querySelector('#chart-container');
element.classList.toggle('full', true); element.classList.toggle('full', true);

49
commands/serve/web/hot.js Normal file
View File

@@ -0,0 +1,49 @@
import { requireFrom } from 'd3-require';
const getModule = name => requireFrom(async n => `/pkg/${encodeURIComponent(n)}`)(name);
const getRemoteModule = url => requireFrom(() => url)();
const hotListeners = {};
const lightItUp = name => {
if (!hotListeners[name]) {
return;
}
hotListeners[name].forEach(fn => fn());
};
const onHotChange = (name, fn) => {
if (!hotListeners[name]) {
hotListeners[name] = [];
}
hotListeners[name].push(fn);
if (window[name]) {
fn();
}
return () => {
// removeListener
const idx = hotListeners[name].indexOf(fn);
hotListeners[name].splice(idx, 1);
};
};
window.onHotChange = onHotChange;
export default function initiateWatch(info) {
const update = () => {
(info.supernova.url ? getRemoteModule(info.supernova.url) : getModule(info.supernova.name)).then(mo => {
window[info.supernova.name] = mo;
lightItUp(info.supernova.name);
});
};
if (info.sock.port) {
const ws = new WebSocket(`ws://localhost:${info.sock.port}`);
ws.onmessage = () => {
update();
};
}
update();
}

View File

@@ -2,7 +2,7 @@ describe('sn', () => {
const content = '.nebulajs-sn'; const content = '.nebulajs-sn';
it('should say hello', async () => { it('should say hello', async () => {
const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf'); const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf');
await page.goto(`${process.env.BASE_URL}/render/app/${app}`); await page.goto(`${process.env.BASE_URL}/render/?app=${app}`);
await page.waitForSelector(content, { visible: true }); await page.waitForSelector(content, { visible: true });