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';
it('should say hello', async () => {
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}')`);
const text = await page.$eval(content, el => el.textContent);
expect(text).to.equal('Hello!');

View File

@@ -3,7 +3,7 @@ describe('interaction', () => {
it('should select two bars', async () => {
const app = encodeURIComponent(process.env.APP_ID || '/apps/ctrl00.qvf');
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 });

View File

@@ -90,7 +90,11 @@ module.exports = async argv => {
let snPath;
let snName;
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);
const parsed = path.parse(snPath);
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({
host,
@@ -119,7 +123,7 @@ module.exports = async argv => {
enigmaConfig,
webIntegrationId: serveConfig.webIntegrationId,
snName: serveConfig.type || snName,
snPath,
snUrl,
dev: process.env.MONO === 'true',
open: serveConfig.open !== false,
entryWatcher: ww,

View File

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

View File

@@ -121,7 +121,9 @@ export default function({ id, expandable, minHeight }) {
title="Open in single render view"
href={
model
? `${document.location.href.replace(/\/dev\//, '/render/')}?object=${
? `${document.location.href.replace(/\/dev\//, '/render/')}${
window.location.search ? '&' : '?'
}object=${
model.id
}&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 SenseUtilities from 'enigma.js/sense-utilities';
import { requireFrom } from 'd3-require';
const params = (() => {
const opts = {};
const { pathname } = window.location;
const am = pathname.match(/\/app\/([^/?&]+)/);
if (am) {
opts.app = decodeURIComponent(am[1]);
}
window.location.search
.substring(1)
.split('&')
@@ -27,96 +20,112 @@ const params = (() => {
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 = {};
const lightItUp = name => {
if (!hotListeners[name]) {
return;
}
hotListeners[name].forEach(fn => fn());
};
const onHotChange = (name, fn) => {
if (!hotListeners[name]) {
hotListeners[name] = [];
if (!m) {
return {
engineUrl: url,
invalid: true,
};
}
hotListeners[name].push(fn);
if (window[name]) {
fn();
let appId;
let engineUrl = url;
let appUrl;
const rxApp = /\/app\/([^?&#:]+)/.exec(url);
if (rxApp) {
[, appId] = rxApp;
engineUrl = url.substring(0, rxApp.index);
appUrl = url;
}
return () => {
// removeListener
const idx = hotListeners[name].indexOf(fn);
hotListeners[name].splice(idx, 1);
return {
enigma: {
secure: m[1] === 'wss',
host: m[2],
port: m[3] || undefined,
appId,
},
engineUrl,
appUrl,
};
};
window.onHotChange = onHotChange;
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')
const connectionInfo = fetch('/info')
.then(response => response.json())
.then(async info => {
initiateWatch(info);
const { webIntegrationId } = info;
const rootPath = `${info.enigma.secure ? 'https' : 'http'}://${info.enigma.host}`;
let headers = {};
if (webIntegrationId) {
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,
.then(async n => {
let info = n;
if (params.engine_url) {
info = {
...info,
...parseEngineURL(params.engine_url),
};
} else if (params.app) {
info = {
...info,
enigma: {
...info.enigma,
appId: params.app,
},
};
}
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 {
...info,
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 = {
host: window.location.hostname || 'localhost',
port: 9076,
secure: false,
};
let connection;
const connect = () => {
if (!connection) {
connection = requestInfo.then(async info => {
const { webIntegrationId, rootPath, headers } = info;
connection = connectionInfo.then(async info => {
const { webIntegrationId, rootPath } = info;
if (webIntegrationId) {
if (!headers) {
headers = await getHeaders(info);
}
return {
getDocList: async () => {
const { data = [] } = await (
@@ -149,10 +158,12 @@ const connect = () => {
};
const openApp = id =>
requestInfo.then(async info => {
const { webIntegrationId, headers } = info;
connectionInfo.then(async info => {
let urlParams = {};
if (webIntegrationId) {
if (info.webIntegrationId) {
if (!headers) {
headers = await getHeaders(info);
}
urlParams = {
...headers,
};
@@ -172,4 +183,4 @@ const openApp = 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 { openApp, params, info } from './connect';
import { openApp, info } from './connect';
import initiateWatch from './hot';
if (!params.app) {
location.href = location.origin; //eslint-disable-line
}
info.then($ =>
openApp(params.app).then(app => {
info.then($ => {
if (!$.enigma.appId) {
window.location.href = `/${window.location.search}`;
}
initiateWatch($);
return openApp($.enigma.appId).then(app => {
ReactDOM.render(<App app={app} info={$} />, document.querySelector('#app'));
})
);
});
});

View File

@@ -1,73 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nebula hub</title>
<head>
<meta charset="UTF-8" />
<title>Nebula hub</title>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: linear-gradient(110deg, #91298C 0%, #45B3B2 100%);
color: #404040;
font: normal 14px/16px "Source Sans Pro", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#apps {
background-color: white;
max-width: 400px;
width: 80%;
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>
body {
background: linear-gradient(110deg, #91298c 0%, #45b3b2 100%);
color: #404040;
font: normal 14px/16px 'Source Sans Pro', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body>
<div id="hub"></div>
</body>
</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 runFixture from './run-fixture';
import initiateWatch from './hot';
const nuke = async ({ app, supernova: { name }, themes, theme, language }) => {
const nuked = nucleus.configured({
@@ -29,11 +30,12 @@ const nuke = async ({ app, supernova: { name }, themes, theme, language }) => {
};
async function renderWithEngine() {
if (!params.app) {
const info = await serverInfo;
initiateWatch(info);
if (!info.enigma.appId) {
location.href = location.origin; //eslint-disable-line
}
const info = await serverInfo;
const app = await openApp(params.app);
const app = await openApp(info.enigma.appId);
const nebbie = await nuke({ app, ...info, theme: params.theme, language: params.language });
const element = document.querySelector('#chart-container');
const vizCfg = {
@@ -67,7 +69,9 @@ async function renderWithEngine() {
}
async function renderSnapshot() {
const { themes, supernova } = await serverInfo;
const info = await serverInfo;
const { themes, supernova } = info;
initiateWatch(info);
const element = document.querySelector('#chart-container');
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';
it('should say hello', async () => {
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 });