@faclon-labs/app-sdk 0.1.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/README.md +54 -0
- package/dist/IOSenseApp.d.ts +37 -0
- package/dist/IOSenseApp.js +77 -0
- package/dist/SignIn.d.ts +24 -0
- package/dist/SignIn.js +72 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +135 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +13 -0
- package/dist/data.d.ts +35 -0
- package/dist/data.js +135 -0
- package/dist/hooks.d.ts +50 -0
- package/dist/hooks.js +135 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/request.d.ts +56 -0
- package/dist/request.js +122 -0
- package/dist/session.d.ts +19 -0
- package/dist/session.js +10 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @faclon-labs/app-sdk
|
|
2
|
+
|
|
3
|
+
IOSense sign-in and plant data for React apps.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @faclon-labs/app-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Sign-in
|
|
10
|
+
|
|
11
|
+
Wrap the app in `<IOSenseApp>`. It takes the session IOSense hands over — the launchpad's SSO
|
|
12
|
+
token (`?token=`, exchanged once), a bearer on the URL, or the `.iosense.io` cookie — takes the token
|
|
13
|
+
out of the address, and keeps it in this browser. With no session it shows a sign-in screen.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { IOSenseApp } from '@faclon-labs/app-sdk';
|
|
17
|
+
|
|
18
|
+
createRoot(document.getElementById('root')!).render(
|
|
19
|
+
<IOSenseApp name="Kiln monitor">
|
|
20
|
+
<App />
|
|
21
|
+
</IOSenseApp>,
|
|
22
|
+
);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| `auth` | With no session |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `"required"` (default) | the sign-in screen |
|
|
28
|
+
| `"optional"` | the app opens; plant data is sample data |
|
|
29
|
+
| `"none"` | no IOSense sign-in at all |
|
|
30
|
+
|
|
31
|
+
`useIOSense()` gives `{ token, apiBaseUrl, sample, signOut }`. Another region or stack:
|
|
32
|
+
`<IOSenseApp apiBaseUrl="https://appserver.us.iosense.io">` or `configure({ apiBaseUrl })`.
|
|
33
|
+
|
|
34
|
+
## Plant data (UNS)
|
|
35
|
+
|
|
36
|
+
Topics are `uns:<workspace>://<path>:<aggregation>`, the same strings io-lens dashboards use.
|
|
37
|
+
`uns:design://…` topics, and every topic while `sample` is true, return steady sample values.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
const { data } = useLatest(['uns:ws_1://plant/kiln/feed:last']); // polls every 60 s
|
|
41
|
+
const { data: trend } = useSeries(['uns:ws_1://plant/kiln/power:average'], { hours: 24, bucket: 'hour' });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The viewer and IOSense's APIs
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
const { user } = useUser(); // { id, email, name, organisation }
|
|
48
|
+
const devices = await request(`${apiBaseUrl}/api/account/devices`, { auth: 'iosense' });
|
|
49
|
+
const { data, loading, error } = useRequest(url, { auth: 'iosense' });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`auth: 'iosense'` adds the viewer's token and is refused for any host that is not IOSense's.
|
|
53
|
+
|
|
54
|
+
Peer dependency: React 18 or later.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
export interface IOSenseAppProps {
|
|
3
|
+
/** The app's name, for the sign-in screen. */
|
|
4
|
+
name: string;
|
|
5
|
+
/**
|
|
6
|
+
* `required` (the default): with no session, the sign-in screen instead of the app — an app on
|
|
7
|
+
* ontology data or IOSense's APIs needs the viewer's IOSense login. Opened from the IOSense
|
|
8
|
+
* launchpad the session arrives on its own and the screen never shows.
|
|
9
|
+
* `optional`: the app opens without a session, and plant data is sample data.
|
|
10
|
+
* `none`: no IOSense sign-in at all (an app that asked for no SSO).
|
|
11
|
+
*/
|
|
12
|
+
auth?: 'required' | 'optional' | 'none';
|
|
13
|
+
/** Force sample data on or off. Left out: sample exactly when there is no session. */
|
|
14
|
+
sample?: boolean;
|
|
15
|
+
/** IOSense's API host, when it is not production's. Same as `configure({ apiBaseUrl })`. */
|
|
16
|
+
apiBaseUrl?: string;
|
|
17
|
+
/** Shown instead of the built-in sign-in screen. Call `onSignedIn(bearer)` when you have a session. */
|
|
18
|
+
signIn?: (props: {
|
|
19
|
+
appName: string;
|
|
20
|
+
apiBaseUrl: string;
|
|
21
|
+
onSignedIn: (token: string) => void;
|
|
22
|
+
}) => ReactNode;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The root of an IOSense app: the session, and the sign-in screen when there is none.
|
|
27
|
+
*
|
|
28
|
+
* The session comes the way IOSense hands it over — the launchpad's SSO token (exchanged before the
|
|
29
|
+
* first render), a bearer on the URL, the `.iosense.io` cookie, or one kept in this browser — and
|
|
30
|
+
* the token is taken out of the URL at once. It renders no chrome of its own.
|
|
31
|
+
*/
|
|
32
|
+
export declare function IOSenseApp({ name, auth, sample, apiBaseUrl, signIn, children }: IOSenseAppProps): import("react").JSX.Element | null;
|
|
33
|
+
/** The badge an app shows while its data is sample data. Renders nothing on live data. */
|
|
34
|
+
export declare function SampleBadge({ className, style }: {
|
|
35
|
+
className?: string;
|
|
36
|
+
style?: CSSProperties;
|
|
37
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { adoptHostSession, clearToken, exchangeSsoToken, getToken, setToken, takePendingSsoToken } from './auth.js';
|
|
4
|
+
import { configure, getConfig } from './config.js';
|
|
5
|
+
import { publishSession } from './request.js';
|
|
6
|
+
import { SAMPLE_NOTICE, SessionContext, useIOSense } from './session.js';
|
|
7
|
+
import { SignIn } from './SignIn.js';
|
|
8
|
+
/**
|
|
9
|
+
* The root of an IOSense app: the session, and the sign-in screen when there is none.
|
|
10
|
+
*
|
|
11
|
+
* The session comes the way IOSense hands it over — the launchpad's SSO token (exchanged before the
|
|
12
|
+
* first render), a bearer on the URL, the `.iosense.io` cookie, or one kept in this browser — and
|
|
13
|
+
* the token is taken out of the URL at once. It renders no chrome of its own.
|
|
14
|
+
*/
|
|
15
|
+
export function IOSenseApp({ name, auth = 'required', sample, apiBaseUrl, signIn, children }) {
|
|
16
|
+
if (apiBaseUrl)
|
|
17
|
+
configure({ apiBaseUrl });
|
|
18
|
+
const api = getConfig().apiBaseUrl;
|
|
19
|
+
// Once, before the first child renders, so the first paint already has the session.
|
|
20
|
+
const [phase, setPhase] = useState(() => {
|
|
21
|
+
if (auth === 'none')
|
|
22
|
+
return { kind: 'ready', token: null };
|
|
23
|
+
adoptHostSession();
|
|
24
|
+
const sso = takePendingSsoToken();
|
|
25
|
+
return sso ? { kind: 'exchanging', sso } : { kind: 'ready', token: getToken() };
|
|
26
|
+
});
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (phase.kind !== 'exchanging')
|
|
29
|
+
return;
|
|
30
|
+
let current = true;
|
|
31
|
+
exchangeSsoToken(phase.sso).then((token) => {
|
|
32
|
+
setToken(token);
|
|
33
|
+
if (current)
|
|
34
|
+
setPhase({ kind: 'ready', token });
|
|
35
|
+
}, () => {
|
|
36
|
+
// A refused SSO token is the same as none: the sign-in screen, or sample data.
|
|
37
|
+
if (current)
|
|
38
|
+
setPhase({ kind: 'ready', token: getToken() });
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
current = false;
|
|
42
|
+
};
|
|
43
|
+
}, [phase]);
|
|
44
|
+
const token = phase.kind === 'ready' ? phase.token : null;
|
|
45
|
+
const session = useMemo(() => ({
|
|
46
|
+
appName: name,
|
|
47
|
+
token,
|
|
48
|
+
apiBaseUrl: api,
|
|
49
|
+
sample: sample ?? (auth !== 'none' && !token),
|
|
50
|
+
sampleNotice: SAMPLE_NOTICE,
|
|
51
|
+
signOut: () => {
|
|
52
|
+
clearToken();
|
|
53
|
+
setPhase({ kind: 'ready', token: null });
|
|
54
|
+
},
|
|
55
|
+
}), [name, token, api, sample, auth]);
|
|
56
|
+
// For `request()`, which is called from event handlers where there is no hook to read from.
|
|
57
|
+
publishSession({ token, apiBaseUrl: api });
|
|
58
|
+
// A blank moment while the SSO token is exchanged: not the sign-in screen, which would be wrong
|
|
59
|
+
// for the half second before the session lands.
|
|
60
|
+
if (phase.kind === 'exchanging')
|
|
61
|
+
return null;
|
|
62
|
+
if (auth === 'required' && !token) {
|
|
63
|
+
const onSignedIn = (bearer) => {
|
|
64
|
+
setToken(bearer);
|
|
65
|
+
setPhase({ kind: 'ready', token: getToken() });
|
|
66
|
+
};
|
|
67
|
+
return _jsx(_Fragment, { children: signIn ? signIn({ appName: name, apiBaseUrl: api, onSignedIn }) : _jsx(SignIn, { appName: name, apiBaseUrl: api, onSignedIn: onSignedIn }) });
|
|
68
|
+
}
|
|
69
|
+
return _jsx(SessionContext.Provider, { value: session, children: children });
|
|
70
|
+
}
|
|
71
|
+
/** The badge an app shows while its data is sample data. Renders nothing on live data. */
|
|
72
|
+
export function SampleBadge({ className, style }) {
|
|
73
|
+
const { sample, sampleNotice } = useIOSense();
|
|
74
|
+
if (!sample)
|
|
75
|
+
return null;
|
|
76
|
+
return (_jsx("span", { "data-iosense-sample": true, className: className, style: className ? style : { display: 'inline-block', padding: '2px 8px', borderRadius: 6, background: '#fef3c7', color: '#78350f', fontSize: 12, fontWeight: 500, ...style }, children: sampleNotice }));
|
|
77
|
+
}
|
package/dist/SignIn.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's sign-in screen: what an app shows when it needs an IOSense session and has none.
|
|
3
|
+
*
|
|
4
|
+
* An app opened from the IOSense launchpad never sees this — the launchpad hands the session over
|
|
5
|
+
* (cookie mode sets `AccessToken` on `.iocompute.ai` through `secure.iocompute.ai/set-and-redirect`;
|
|
6
|
+
* SSO mode puts `?token=` on the URL) and `IOSenseApp` picks it up before the first paint. Inside the
|
|
7
|
+
* Studio the frame shares the Studio's origin and reads its token. This screen is for the third
|
|
8
|
+
* case: the app's own address, opened directly, by someone who has an IOSense session elsewhere.
|
|
9
|
+
*
|
|
10
|
+
* It takes a token, not a username and a password. An app is not IOSense: a password typed into an
|
|
11
|
+
* app's page is a password handed to whatever that page runs, and accounts with a second factor
|
|
12
|
+
* could not sign in here at all. What it wants is an IOSense SSO token — the 64-character one
|
|
13
|
+
* `generate-sso-token` mints, the same thing the launchpad hands over — which it exchanges for the
|
|
14
|
+
* session (`exchangeSsoToken`) and keeps the same way (`setToken`). A bearer pasted instead is
|
|
15
|
+
* taken too, after one request checks that IOSense accepts it: a wrong paste is one line here
|
|
16
|
+
* instead of an app full of failed requests.
|
|
17
|
+
*
|
|
18
|
+
* Plain markup and the theme's tokens, so it looks like the app it opens, whatever the app's own CSS.
|
|
19
|
+
*/
|
|
20
|
+
export declare function SignIn({ appName, apiBaseUrl, onSignedIn }: {
|
|
21
|
+
appName: string;
|
|
22
|
+
apiBaseUrl: string;
|
|
23
|
+
onSignedIn: (token: string) => void;
|
|
24
|
+
}): import("react").JSX.Element;
|
package/dist/SignIn.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { exchangeSsoToken, isSsoToken } from './auth.js';
|
|
4
|
+
/**
|
|
5
|
+
* The SDK's sign-in screen: what an app shows when it needs an IOSense session and has none.
|
|
6
|
+
*
|
|
7
|
+
* An app opened from the IOSense launchpad never sees this — the launchpad hands the session over
|
|
8
|
+
* (cookie mode sets `AccessToken` on `.iocompute.ai` through `secure.iocompute.ai/set-and-redirect`;
|
|
9
|
+
* SSO mode puts `?token=` on the URL) and `IOSenseApp` picks it up before the first paint. Inside the
|
|
10
|
+
* Studio the frame shares the Studio's origin and reads its token. This screen is for the third
|
|
11
|
+
* case: the app's own address, opened directly, by someone who has an IOSense session elsewhere.
|
|
12
|
+
*
|
|
13
|
+
* It takes a token, not a username and a password. An app is not IOSense: a password typed into an
|
|
14
|
+
* app's page is a password handed to whatever that page runs, and accounts with a second factor
|
|
15
|
+
* could not sign in here at all. What it wants is an IOSense SSO token — the 64-character one
|
|
16
|
+
* `generate-sso-token` mints, the same thing the launchpad hands over — which it exchanges for the
|
|
17
|
+
* session (`exchangeSsoToken`) and keeps the same way (`setToken`). A bearer pasted instead is
|
|
18
|
+
* taken too, after one request checks that IOSense accepts it: a wrong paste is one line here
|
|
19
|
+
* instead of an app full of failed requests.
|
|
20
|
+
*
|
|
21
|
+
* Plain markup and the theme's tokens, so it looks like the app it opens, whatever the app's own CSS.
|
|
22
|
+
*/
|
|
23
|
+
export function SignIn({ appName, apiBaseUrl, onSignedIn }) {
|
|
24
|
+
const [token, setToken] = useState('');
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
async function submit(event) {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
// A token copied from a URL may carry `Bearer ` or quotes; what IOSense wants is the value alone.
|
|
30
|
+
const value = token.trim().replace(/^Bearer\s+/i, '').replace(/^["']|["']$/g, '');
|
|
31
|
+
if (!value)
|
|
32
|
+
return;
|
|
33
|
+
setBusy(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
if (isSsoToken(value)) {
|
|
36
|
+
try {
|
|
37
|
+
const bearer = await exchangeSsoToken(value);
|
|
38
|
+
setToken('');
|
|
39
|
+
onSignedIn(bearer);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// The server's own words ("Invalid or expired token.") say what to do: mint a new one.
|
|
43
|
+
setError(`${err instanceof Error ? err.message : 'IOSense did not accept this token.'} An SSO token works once — copy a fresh one.`);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setBusy(false);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
// The call IOSense itself makes for the signed-in account (auth.service.ts): a 401 is a clear no.
|
|
52
|
+
const res = await fetch(`${apiBaseUrl}/api/account/getUserDetails2`, { headers: { Authorization: `Bearer ${value}` } });
|
|
53
|
+
// Only a clear "no" stops the sign-in. Any other answer — the route moved, the server is
|
|
54
|
+
// down — lets the app open; its own requests will say what is wrong, and a token that is
|
|
55
|
+
// right should never be refused by a check that could not run.
|
|
56
|
+
if (res.status === 401 || res.status === 403) {
|
|
57
|
+
setError('IOSense did not accept this token. Copy a fresh one from a signed-in IOSense session.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Unreachable: the app opens, and shows the reach problem where it shows its data.
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setBusy(false);
|
|
66
|
+
}
|
|
67
|
+
setToken('');
|
|
68
|
+
onSignedIn(value);
|
|
69
|
+
}
|
|
70
|
+
const canSubmit = !busy && token.trim().length > 0;
|
|
71
|
+
return (_jsx("div", { "data-iosense-signin": true, style: { minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24, background: 'var(--background, #f7f7f4)', color: 'var(--foreground, #161616)', fontFamily: 'inherit' }, children: _jsxs("form", { onSubmit: submit, style: { width: 'min(380px, 100%)', display: 'flex', flexDirection: 'column', gap: 12, padding: 24, borderRadius: 12, background: 'var(--card, #fff)', border: '1px solid var(--border, #e2e0da)' }, children: [_jsxs("div", { children: [_jsxs("h1", { style: { margin: 0, fontSize: 18, fontWeight: 600 }, children: ["Sign in to ", appName] }), _jsx("p", { style: { margin: '4px 0 0', fontSize: 13, color: 'var(--muted-foreground, #5c5c5c)' }, children: "This app reads your plant through IOSense. Paste an SSO token from your IOSense session." })] }), _jsxs("label", { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13, fontWeight: 500 }, children: ["SSO token", _jsx("input", { name: "token", autoComplete: "off", autoFocus: true, spellCheck: false, placeholder: "Paste an SSO token from IOSense", value: token, onChange: (e) => setToken(e.target.value), disabled: busy, style: { width: '100%', boxSizing: 'border-box', height: 40, padding: '0 12px', border: '1px solid var(--border, #d4d4d8)', borderRadius: 8, background: 'var(--background, #fff)', color: 'inherit', font: 'inherit' } })] }), error && (_jsx("p", { role: "alert", style: { margin: 0, fontSize: 13, color: 'var(--destructive, #b4302c)' }, children: error })), _jsx("button", { type: "submit", disabled: !canSubmit, style: { height: 40, border: 0, borderRadius: 999, background: 'var(--primary, #1364f1)', color: 'var(--primary-foreground, #fff)', font: 'inherit', fontWeight: 500, cursor: busy ? 'progress' : 'pointer', opacity: canSubmit ? 1 : 0.6 }, children: busy ? 'Checking…' : 'Continue' }), _jsx("p", { style: { margin: 0, fontSize: 12, color: 'var(--muted-foreground, #5c5c5c)' }, children: "Opened from the IOSense launchpad, this app signs you in on its own \u2014 this screen is for opening it by its own address." })] }) }));
|
|
72
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A launchpad SSO token: 64 hex characters. A bearer is a JWT, far longer and with dots. */
|
|
2
|
+
export declare function isSsoToken(value: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Turn an SSO token into the session it stands for. IOSense answers each SSO token once; the
|
|
5
|
+
* exchange is shared per token (React's StrictMode runs effects twice), and only a failed one is
|
|
6
|
+
* forgotten. Resolves with the bearer, without its `Bearer ` prefix.
|
|
7
|
+
*/
|
|
8
|
+
export declare function exchangeSsoToken(sso: string): Promise<string>;
|
|
9
|
+
/** The SSO token the URL handed over, once. */
|
|
10
|
+
export declare function takePendingSsoToken(): string | null;
|
|
11
|
+
/** The bearer of the signed-in viewer, or null. */
|
|
12
|
+
export declare function getToken(): string | null;
|
|
13
|
+
export declare function setToken(token: string): void;
|
|
14
|
+
/** Sign out in this browser: the session is forgotten. */
|
|
15
|
+
export declare function clearToken(): void;
|
|
16
|
+
/** True when this page runs inside another page's frame — the IOSense launchpad, normally. */
|
|
17
|
+
export declare function isEmbedded(): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Take the session the host handed over, and take the token out of the URL. Called once, before
|
|
20
|
+
* the first render (`<IOSenseApp>` does it). Returns how the session arrived.
|
|
21
|
+
*/
|
|
22
|
+
export declare function adoptHostSession(): 'sso' | 'url' | 'cookie' | 'stored' | 'none';
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { getConfig } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* The IOSense session: how an app gets it, and where it keeps it.
|
|
4
|
+
*
|
|
5
|
+
* Four ways in, in order:
|
|
6
|
+
* 1. **Launchpad, SSO mode** — IOSense opens the app as `…?token=<sso>`: a 64-hex SSO token,
|
|
7
|
+
* exchanged ONCE for the bearer (`exchangeSsoToken`) before the app renders.
|
|
8
|
+
* 2. **A bearer on the URL** (`?token=<jwt>`), taken as it is.
|
|
9
|
+
* 3. **Launchpad, cookie mode** — the access token as a cookie on `.iosense.io`.
|
|
10
|
+
* 4. **Stored** — a session kept in this browser from before, or the sign-in screen.
|
|
11
|
+
*
|
|
12
|
+
* The token is taken out of the URL at once: a token in the address is copied into bookmarks,
|
|
13
|
+
* shared links and `Referer` headers. It lives in memory first and localStorage second; storage in a
|
|
14
|
+
* cross-site iframe can be partitioned or blocked, so it is only ever best effort.
|
|
15
|
+
*/
|
|
16
|
+
const TOKEN_PARAM = 'token';
|
|
17
|
+
let memoryToken = null;
|
|
18
|
+
let pendingSso = null;
|
|
19
|
+
/** A launchpad SSO token: 64 hex characters. A bearer is a JWT, far longer and with dots. */
|
|
20
|
+
export function isSsoToken(value) {
|
|
21
|
+
return /^[0-9a-f]{64}$/i.test(value.trim());
|
|
22
|
+
}
|
|
23
|
+
const exchanges = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* Turn an SSO token into the session it stands for. IOSense answers each SSO token once; the
|
|
26
|
+
* exchange is shared per token (React's StrictMode runs effects twice), and only a failed one is
|
|
27
|
+
* forgotten. Resolves with the bearer, without its `Bearer ` prefix.
|
|
28
|
+
*/
|
|
29
|
+
export function exchangeSsoToken(sso) {
|
|
30
|
+
const key = sso.trim();
|
|
31
|
+
let inFlight = exchanges.get(key);
|
|
32
|
+
if (!inFlight) {
|
|
33
|
+
inFlight = (async () => {
|
|
34
|
+
const res = await fetch(`${getConfig().ssoExchangeUrl}/${encodeURIComponent(key)}`);
|
|
35
|
+
const body = (await res.json().catch(() => null));
|
|
36
|
+
if (!body?.success || !body.token) {
|
|
37
|
+
const said = body?.errors?.[0];
|
|
38
|
+
throw new Error(typeof said === 'string' ? said : `IOSense did not accept the SSO token (${res.status}).`);
|
|
39
|
+
}
|
|
40
|
+
return body.token.replace(/^Bearer\s+/i, '');
|
|
41
|
+
})();
|
|
42
|
+
exchanges.set(key, inFlight);
|
|
43
|
+
inFlight.catch(() => exchanges.delete(key));
|
|
44
|
+
}
|
|
45
|
+
return inFlight;
|
|
46
|
+
}
|
|
47
|
+
/** The SSO token the URL handed over, once. */
|
|
48
|
+
export function takePendingSsoToken() {
|
|
49
|
+
const sso = pendingSso;
|
|
50
|
+
pendingSso = null;
|
|
51
|
+
return sso;
|
|
52
|
+
}
|
|
53
|
+
function readStored() {
|
|
54
|
+
try {
|
|
55
|
+
return localStorage.getItem(getConfig().storageKey);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The bearer of the signed-in viewer, or null. */
|
|
62
|
+
export function getToken() {
|
|
63
|
+
if (memoryToken)
|
|
64
|
+
return memoryToken;
|
|
65
|
+
memoryToken = readStored();
|
|
66
|
+
return memoryToken;
|
|
67
|
+
}
|
|
68
|
+
export function setToken(token) {
|
|
69
|
+
memoryToken = token.replace(/^Bearer\s+/i, '');
|
|
70
|
+
try {
|
|
71
|
+
localStorage.setItem(getConfig().storageKey, memoryToken);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// The session still works for as long as this page lives.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Sign out in this browser: the session is forgotten. */
|
|
78
|
+
export function clearToken() {
|
|
79
|
+
memoryToken = null;
|
|
80
|
+
try {
|
|
81
|
+
localStorage.removeItem(getConfig().storageKey);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Nothing more to do: the in-memory copy is gone, and that is what the app reads.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** True when this page runs inside another page's frame — the IOSense launchpad, normally. */
|
|
88
|
+
export function isEmbedded() {
|
|
89
|
+
try {
|
|
90
|
+
return window.self !== window.top;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function readCookie(name) {
|
|
97
|
+
const prefix = `${name}=`;
|
|
98
|
+
for (const part of document.cookie.split(';')) {
|
|
99
|
+
const entry = part.trim();
|
|
100
|
+
if (entry.startsWith(prefix))
|
|
101
|
+
return decodeURIComponent(entry.slice(prefix.length));
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Take the session the host handed over, and take the token out of the URL. Called once, before
|
|
107
|
+
* the first render (`<IOSenseApp>` does it). Returns how the session arrived.
|
|
108
|
+
*/
|
|
109
|
+
export function adoptHostSession() {
|
|
110
|
+
if (typeof window === 'undefined')
|
|
111
|
+
return 'none';
|
|
112
|
+
const url = new URL(window.location.href);
|
|
113
|
+
const hash = new URLSearchParams(url.hash.replace(/^#/, ''));
|
|
114
|
+
const handed = url.searchParams.get(TOKEN_PARAM) ?? hash.get(TOKEN_PARAM);
|
|
115
|
+
if (handed?.trim()) {
|
|
116
|
+
// IOSense builds the iframe URL without encoding it, so a `+` in a token arrives as a space.
|
|
117
|
+
const value = handed.trim().replaceAll(' ', '+');
|
|
118
|
+
const sso = isSsoToken(value);
|
|
119
|
+
if (sso)
|
|
120
|
+
pendingSso = value;
|
|
121
|
+
else
|
|
122
|
+
setToken(value);
|
|
123
|
+
url.searchParams.delete(TOKEN_PARAM);
|
|
124
|
+
hash.delete(TOKEN_PARAM);
|
|
125
|
+
const rest = hash.toString();
|
|
126
|
+
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${rest ? `#${rest}` : ''}`);
|
|
127
|
+
return sso ? 'sso' : 'url';
|
|
128
|
+
}
|
|
129
|
+
const cookie = readCookie(getConfig().tokenCookie);
|
|
130
|
+
if (cookie?.trim()) {
|
|
131
|
+
setToken(cookie.trim());
|
|
132
|
+
return 'cookie';
|
|
133
|
+
}
|
|
134
|
+
return getToken() ? 'stored' : 'none';
|
|
135
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where IOSense is. The defaults are IOSense's production hosts; an app on another region or a
|
|
3
|
+
* staging stack sets them once, before rendering (`configure({ apiBaseUrl: … })`), or passes
|
|
4
|
+
* `apiBaseUrl` to `<IOSenseApp>`.
|
|
5
|
+
*/
|
|
6
|
+
export interface IOSenseConfig {
|
|
7
|
+
/** IOSense's API host. Default `https://appserver.iosense.io`. */
|
|
8
|
+
apiBaseUrl: string;
|
|
9
|
+
/** Where a launchpad SSO token is exchanged for a session. */
|
|
10
|
+
ssoExchangeUrl: string;
|
|
11
|
+
/** Cookie mode's name for the access token on `.iosense.io`. */
|
|
12
|
+
tokenCookie: string;
|
|
13
|
+
/** Where the session is kept in this browser. */
|
|
14
|
+
storageKey: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function configure(next: Partial<IOSenseConfig>): void;
|
|
17
|
+
export declare const getConfig: () => Readonly<IOSenseConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const config = {
|
|
2
|
+
apiBaseUrl: 'https://appserver.iosense.io',
|
|
3
|
+
ssoExchangeUrl: 'https://connector.iosense.io/api/retrieve-sso-token',
|
|
4
|
+
tokenCookie: 'AccessToken',
|
|
5
|
+
storageKey: 'bearer_token',
|
|
6
|
+
};
|
|
7
|
+
export function configure(next) {
|
|
8
|
+
for (const [key, value] of Object.entries(next)) {
|
|
9
|
+
if (typeof value === 'string' && value)
|
|
10
|
+
config[key] = key.endsWith('Url') ? value.replace(/\/$/, '') : value;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const getConfig = () => config;
|
package/dist/data.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNS reads: the latest value of a topic, and its values over a window.
|
|
3
|
+
*
|
|
4
|
+
* A topic is `uns:<workspace>://<path>[:<aggregation>]` — the same string io-lens dashboards bind
|
|
5
|
+
* widgets to (`uns:ws_1://plant/kiln/feed:last`, `…/power:average`). The read is IOSense's
|
|
6
|
+
* `resolveAndCompute`, the one dashboards make. Sample topics (`uns:design://…`), and every topic
|
|
7
|
+
* while the app shows sample data, are answered here with steady made-up values, so a screen can be
|
|
8
|
+
* built and shown before it is bound to a plant.
|
|
9
|
+
*/
|
|
10
|
+
export interface SeriesPoint {
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
label?: string;
|
|
14
|
+
value: number | null;
|
|
15
|
+
}
|
|
16
|
+
export declare const isSampleTopic: (topic: string) => boolean;
|
|
17
|
+
export declare function noteTopics(topics: readonly string[]): void;
|
|
18
|
+
/** The same read asked for within a second by two components (or StrictMode) is one request. */
|
|
19
|
+
export declare function sharedRead<T>(key: string, read: () => Promise<T>, now?: number): Promise<T>;
|
|
20
|
+
/** The latest value of each topic, in one request. */
|
|
21
|
+
export declare function fetchLatest(topics: string[], opts: {
|
|
22
|
+
apiBaseUrl: string;
|
|
23
|
+
token: string | null;
|
|
24
|
+
sample: boolean;
|
|
25
|
+
}): Promise<Record<string, unknown>>;
|
|
26
|
+
/** Each topic's values over a window, bucketed. The topic's suffix is the aggregation. */
|
|
27
|
+
export declare function fetchSeries(topics: string[], window: {
|
|
28
|
+
startTime: number;
|
|
29
|
+
endTime: number;
|
|
30
|
+
timeFrame: string;
|
|
31
|
+
}, opts: {
|
|
32
|
+
apiBaseUrl: string;
|
|
33
|
+
token: string | null;
|
|
34
|
+
sample: boolean;
|
|
35
|
+
}): Promise<Record<string, SeriesPoint[]>>;
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNS reads: the latest value of a topic, and its values over a window.
|
|
3
|
+
*
|
|
4
|
+
* A topic is `uns:<workspace>://<path>[:<aggregation>]` — the same string io-lens dashboards bind
|
|
5
|
+
* widgets to (`uns:ws_1://plant/kiln/feed:last`, `…/power:average`). The read is IOSense's
|
|
6
|
+
* `resolveAndCompute`, the one dashboards make. Sample topics (`uns:design://…`), and every topic
|
|
7
|
+
* while the app shows sample data, are answered here with steady made-up values, so a screen can be
|
|
8
|
+
* built and shown before it is bound to a plant.
|
|
9
|
+
*/
|
|
10
|
+
const RESOLVE = '/api/account/uns/resolveAndCompute';
|
|
11
|
+
export const isSampleTopic = (topic) => topic.startsWith('uns:design://');
|
|
12
|
+
/* ---------- topics a page reads (the Studio's preview listens) ---------- */
|
|
13
|
+
const topicsRead = new Set();
|
|
14
|
+
export function noteTopics(topics) {
|
|
15
|
+
const fresh = topics.filter((t) => !topicsRead.has(t));
|
|
16
|
+
if (!fresh.length)
|
|
17
|
+
return;
|
|
18
|
+
fresh.forEach((t) => topicsRead.add(t));
|
|
19
|
+
// An io-lens Studio preview lists the tags an app reads, to check that they report.
|
|
20
|
+
globalThis.__iolensTopics?.([...topicsRead]);
|
|
21
|
+
}
|
|
22
|
+
/* ---------- reads in flight, shared ---------- */
|
|
23
|
+
const inFlight = new Map();
|
|
24
|
+
const SHARE_MS = 1000;
|
|
25
|
+
/** The same read asked for within a second by two components (or StrictMode) is one request. */
|
|
26
|
+
export function sharedRead(key, read, now = Date.now()) {
|
|
27
|
+
const hit = inFlight.get(key);
|
|
28
|
+
if (hit && now - hit.at < SHARE_MS)
|
|
29
|
+
return hit.promise;
|
|
30
|
+
const promise = read();
|
|
31
|
+
inFlight.set(key, { at: now, promise });
|
|
32
|
+
promise.finally(() => setTimeout(() => inFlight.get(key)?.promise === promise && inFlight.delete(key), SHARE_MS)).catch(() => undefined);
|
|
33
|
+
return promise;
|
|
34
|
+
}
|
|
35
|
+
/* ---------- sample values ---------- */
|
|
36
|
+
function hash(text) {
|
|
37
|
+
let h = 2166136261;
|
|
38
|
+
for (let i = 0; i < text.length; i++)
|
|
39
|
+
h = Math.imul(h ^ text.charCodeAt(i), 16777619);
|
|
40
|
+
return h >>> 0;
|
|
41
|
+
}
|
|
42
|
+
function sampleValue(topic) {
|
|
43
|
+
const seed = hash(topic);
|
|
44
|
+
return Math.round((40 + (seed % 55) + (seed % 10)) * 10) / 10;
|
|
45
|
+
}
|
|
46
|
+
function sampleSeries(topic, from, to, buckets) {
|
|
47
|
+
const seed = hash(topic);
|
|
48
|
+
const base = 40 + (seed % 55);
|
|
49
|
+
const amp = 8 + (seed % 12);
|
|
50
|
+
const step = Math.max(60000, Math.floor((to - from) / buckets));
|
|
51
|
+
return Array.from({ length: buckets }, (_, i) => {
|
|
52
|
+
const start = from + i * step;
|
|
53
|
+
const d = new Date(start);
|
|
54
|
+
return { from: start, to: start + step, label: `${String(d.getHours()).padStart(2, '0')}:00`, value: Math.round((base + amp * Math.sin((i + (seed % 7)) / 3)) * 10) / 10 };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/* ---------- the read ---------- */
|
|
58
|
+
function body(config, startTime, endTime, timeFrame) {
|
|
59
|
+
return {
|
|
60
|
+
cycleYear: 'calendar',
|
|
61
|
+
yearStart: 1,
|
|
62
|
+
monthStart: 1,
|
|
63
|
+
weekStart: 0,
|
|
64
|
+
cycleTime: '00:00',
|
|
65
|
+
cycleTimeIdentifier: 'start',
|
|
66
|
+
shifts: [],
|
|
67
|
+
shiftAggregator: null,
|
|
68
|
+
disableClipping: false,
|
|
69
|
+
config,
|
|
70
|
+
startTime,
|
|
71
|
+
endTime,
|
|
72
|
+
timeFrame,
|
|
73
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
|
74
|
+
inExactMultiple: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function resolve(apiBaseUrl, token, payload) {
|
|
78
|
+
const res = await fetch(`${apiBaseUrl}${RESOLVE}`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
81
|
+
body: JSON.stringify(payload),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok)
|
|
84
|
+
throw new Error(`IOSense answered ${res.status} to a data read.`);
|
|
85
|
+
const json = (await res.json());
|
|
86
|
+
const data = Array.isArray(json) ? json : json.data;
|
|
87
|
+
if (json && typeof json === 'object' && !Array.isArray(json) && json.success === false) {
|
|
88
|
+
throw new Error(String(json.message ?? 'IOSense refused the data read.'));
|
|
89
|
+
}
|
|
90
|
+
return Array.isArray(data) ? data : [];
|
|
91
|
+
}
|
|
92
|
+
/** The latest value of each topic, in one request. */
|
|
93
|
+
export async function fetchLatest(topics, opts) {
|
|
94
|
+
const out = {};
|
|
95
|
+
const real = [];
|
|
96
|
+
for (const topic of topics) {
|
|
97
|
+
if (opts.sample || isSampleTopic(topic))
|
|
98
|
+
out[topic] = sampleValue(topic);
|
|
99
|
+
else
|
|
100
|
+
real.push(topic);
|
|
101
|
+
}
|
|
102
|
+
if (!real.length)
|
|
103
|
+
return out;
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const entries = await resolve(opts.apiBaseUrl, opts.token, body(real.map((topic) => ({ key: topic, topic })), now - 24 * 3600000, now, 'hour'));
|
|
106
|
+
for (const topic of real)
|
|
107
|
+
out[topic] = null;
|
|
108
|
+
for (const e of entries)
|
|
109
|
+
if (e && typeof e.key === 'string')
|
|
110
|
+
out[e.key] = e.value ?? null;
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Each topic's values over a window, bucketed. The topic's suffix is the aggregation. */
|
|
114
|
+
export async function fetchSeries(topics, window, opts) {
|
|
115
|
+
const out = {};
|
|
116
|
+
const real = [];
|
|
117
|
+
const buckets = Math.max(1, Math.min(200, Math.round((window.endTime - window.startTime) / ({ minute: 60e3, hour: 3600e3, day: 86400e3, week: 604800e3, month: 2592000e3 }[window.timeFrame] ?? 3600e3))));
|
|
118
|
+
for (const topic of topics) {
|
|
119
|
+
if (opts.sample || isSampleTopic(topic))
|
|
120
|
+
out[topic] = sampleSeries(topic, window.startTime, window.endTime, buckets);
|
|
121
|
+
else
|
|
122
|
+
real.push(topic);
|
|
123
|
+
}
|
|
124
|
+
if (!real.length)
|
|
125
|
+
return out;
|
|
126
|
+
const entries = await resolve(opts.apiBaseUrl, opts.token, body(real.map((topic) => ({ key: topic, topic, type: 'series' })), window.startTime, window.endTime, window.timeFrame));
|
|
127
|
+
for (const topic of real)
|
|
128
|
+
out[topic] = [];
|
|
129
|
+
for (const e of entries) {
|
|
130
|
+
if (!e || typeof e.key !== 'string' || !Array.isArray(e.slots))
|
|
131
|
+
continue;
|
|
132
|
+
out[e.key] = e.slots.map((s) => ({ from: Number(s.from), to: Number(s.to), ...(s.label ? { label: String(s.label) } : {}), value: s.value == null || s.value === '' ? null : Number(s.value) }));
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type SeriesPoint } from './data.js';
|
|
2
|
+
export interface LatestOptions {
|
|
3
|
+
/** Poll interval. Default 60 s; 0 reads once. */
|
|
4
|
+
refreshMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface LatestResult {
|
|
7
|
+
/** Topic → its latest value. Absent until the first read; null when the plant has none. */
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
loading: boolean;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
/** Read again now. */
|
|
12
|
+
refresh: () => void;
|
|
13
|
+
}
|
|
14
|
+
/** The latest value of each UNS topic, polled. */
|
|
15
|
+
export declare function useLatest(topics: readonly string[], options?: LatestOptions): LatestResult;
|
|
16
|
+
export interface SeriesOptions {
|
|
17
|
+
/** How far back from now. Default 24. */
|
|
18
|
+
hours?: number;
|
|
19
|
+
/** Bucket size. Default 'hour'. */
|
|
20
|
+
bucket?: 'minute' | 'hour' | 'day' | 'week' | 'month';
|
|
21
|
+
/** Poll interval. Default 5 minutes; 0 reads once. */
|
|
22
|
+
refreshMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface SeriesResult {
|
|
25
|
+
/** Topic → its points, oldest first. */
|
|
26
|
+
data: Record<string, SeriesPoint[]>;
|
|
27
|
+
loading: boolean;
|
|
28
|
+
error: Error | null;
|
|
29
|
+
refresh: () => void;
|
|
30
|
+
}
|
|
31
|
+
/** Each UNS topic's values over the last hours — for a trend, a table or a sparkline. */
|
|
32
|
+
export declare function useSeries(topics: readonly string[], options?: SeriesOptions): SeriesResult;
|
|
33
|
+
/** Who is signed in. IOSense has no named roles; gate on organisation, or your own list of ids or emails. */
|
|
34
|
+
export interface IOSenseUser {
|
|
35
|
+
id: string;
|
|
36
|
+
email: string;
|
|
37
|
+
name: string;
|
|
38
|
+
organisation: {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export interface UserResult {
|
|
44
|
+
/** Null until read and while nobody is signed in; a sample user while the app shows sample data. */
|
|
45
|
+
user: IOSenseUser | null;
|
|
46
|
+
loading: boolean;
|
|
47
|
+
error: Error | null;
|
|
48
|
+
}
|
|
49
|
+
/** The signed-in viewer: id, email, name, organisation. */
|
|
50
|
+
export declare function useUser(): UserResult;
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { fetchLatest, fetchSeries, noteTopics, sharedRead } from './data.js';
|
|
3
|
+
import { requestWith } from './request.js';
|
|
4
|
+
import { useIOSense } from './session.js';
|
|
5
|
+
/** The latest value of each UNS topic, polled. */
|
|
6
|
+
export function useLatest(topics, options = {}) {
|
|
7
|
+
const { apiBaseUrl, token, sample } = useIOSense();
|
|
8
|
+
const refreshMs = options.refreshMs ?? 60000;
|
|
9
|
+
const key = topics.join('\n');
|
|
10
|
+
const [data, setData] = useState({});
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const generation = useRef(0);
|
|
14
|
+
const read = useCallback(() => {
|
|
15
|
+
const wanted = key ? key.split('\n') : [];
|
|
16
|
+
if (!wanted.length) {
|
|
17
|
+
setData({});
|
|
18
|
+
setLoading(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const mine = ++generation.current;
|
|
22
|
+
noteTopics(wanted);
|
|
23
|
+
sharedRead(`latest\u0000${apiBaseUrl}\u0000${token ?? ''}\u0000${sample ? 1 : 0}\u0000${key}`, () => fetchLatest(wanted, { apiBaseUrl, token, sample }))
|
|
24
|
+
.then((values) => {
|
|
25
|
+
if (mine !== generation.current)
|
|
26
|
+
return;
|
|
27
|
+
setData(values);
|
|
28
|
+
setError(null);
|
|
29
|
+
})
|
|
30
|
+
.catch((e) => {
|
|
31
|
+
if (mine === generation.current)
|
|
32
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
if (mine === generation.current)
|
|
36
|
+
setLoading(false);
|
|
37
|
+
});
|
|
38
|
+
}, [key, apiBaseUrl, token, sample]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setLoading(true);
|
|
41
|
+
read();
|
|
42
|
+
if (!refreshMs)
|
|
43
|
+
return;
|
|
44
|
+
const timer = setInterval(read, refreshMs);
|
|
45
|
+
return () => clearInterval(timer);
|
|
46
|
+
}, [read, refreshMs]);
|
|
47
|
+
return { data, loading, error, refresh: read };
|
|
48
|
+
}
|
|
49
|
+
/** Each UNS topic's values over the last hours — for a trend, a table or a sparkline. */
|
|
50
|
+
export function useSeries(topics, options = {}) {
|
|
51
|
+
const { apiBaseUrl, token, sample } = useIOSense();
|
|
52
|
+
const hours = options.hours ?? 24;
|
|
53
|
+
const bucket = options.bucket ?? 'hour';
|
|
54
|
+
const refreshMs = options.refreshMs ?? 300000;
|
|
55
|
+
const key = topics.join('\n');
|
|
56
|
+
const [data, setData] = useState({});
|
|
57
|
+
const [loading, setLoading] = useState(true);
|
|
58
|
+
const [error, setError] = useState(null);
|
|
59
|
+
const generation = useRef(0);
|
|
60
|
+
const read = useCallback(() => {
|
|
61
|
+
const wanted = key ? key.split('\n') : [];
|
|
62
|
+
if (!wanted.length) {
|
|
63
|
+
setData({});
|
|
64
|
+
setLoading(false);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const mine = ++generation.current;
|
|
68
|
+
noteTopics(wanted);
|
|
69
|
+
const endTime = Math.floor(Date.now() / 60000) * 60000;
|
|
70
|
+
const window = { startTime: endTime - hours * 3600000, endTime, timeFrame: bucket };
|
|
71
|
+
sharedRead(`series\u0000${apiBaseUrl}\u0000${token ?? ''}\u0000${sample ? 1 : 0}\u0000${hours}\u0000${bucket}\u0000${endTime}\u0000${key}`, () => fetchSeries(wanted, window, { apiBaseUrl, token, sample }))
|
|
72
|
+
.then((values) => {
|
|
73
|
+
if (mine !== generation.current)
|
|
74
|
+
return;
|
|
75
|
+
setData(values);
|
|
76
|
+
setError(null);
|
|
77
|
+
})
|
|
78
|
+
.catch((e) => {
|
|
79
|
+
if (mine === generation.current)
|
|
80
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
81
|
+
})
|
|
82
|
+
.finally(() => {
|
|
83
|
+
if (mine === generation.current)
|
|
84
|
+
setLoading(false);
|
|
85
|
+
});
|
|
86
|
+
}, [key, apiBaseUrl, token, sample, hours, bucket]);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setLoading(true);
|
|
89
|
+
read();
|
|
90
|
+
if (!refreshMs)
|
|
91
|
+
return;
|
|
92
|
+
const timer = setInterval(read, refreshMs);
|
|
93
|
+
return () => clearInterval(timer);
|
|
94
|
+
}, [read, refreshMs]);
|
|
95
|
+
return { data, loading, error, refresh: read };
|
|
96
|
+
}
|
|
97
|
+
const SAMPLE_USER = { id: 'sample', email: 'sample.user@example.com', name: 'Sample user', organisation: { id: 'sample', name: 'Sample plant' } };
|
|
98
|
+
const userReads = new Map();
|
|
99
|
+
function readUser(token, apiBaseUrl) {
|
|
100
|
+
const key = `${apiBaseUrl}\u0000${token}`;
|
|
101
|
+
let read = userReads.get(key);
|
|
102
|
+
if (!read) {
|
|
103
|
+
// `getUserDetails2`, never `getUserDetails`: the older one also returns credential fields.
|
|
104
|
+
read = requestWith(token, apiBaseUrl, `${apiBaseUrl}/api/account/getUserDetails2`, { auth: 'iosense' }).then((body) => {
|
|
105
|
+
const u = body?.data?.user;
|
|
106
|
+
if (!u?.id)
|
|
107
|
+
throw new Error('IOSense did not say who is signed in.');
|
|
108
|
+
const name = [u.userDetail?.personalDetails?.name?.first, u.userDetail?.personalDetails?.name?.last].filter(Boolean).join(' ').trim();
|
|
109
|
+
return { id: u.id, email: u.email ?? '', name: name || u.email || '', organisation: { id: u.organisation?._id ?? '', name: u.organisation?.orgName ?? '' } };
|
|
110
|
+
});
|
|
111
|
+
userReads.set(key, read);
|
|
112
|
+
read.catch(() => userReads.delete(key));
|
|
113
|
+
}
|
|
114
|
+
return read;
|
|
115
|
+
}
|
|
116
|
+
/** The signed-in viewer: id, email, name, organisation. */
|
|
117
|
+
export function useUser() {
|
|
118
|
+
const { token, apiBaseUrl, sample } = useIOSense();
|
|
119
|
+
const [state, setState] = useState({ user: sample ? SAMPLE_USER : null, loading: !sample && !!token, error: null });
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (sample)
|
|
122
|
+
return setState({ user: SAMPLE_USER, loading: false, error: null });
|
|
123
|
+
if (!token)
|
|
124
|
+
return setState({ user: null, loading: false, error: null });
|
|
125
|
+
let live = true;
|
|
126
|
+
setState((s) => ({ ...s, loading: true }));
|
|
127
|
+
readUser(token, apiBaseUrl)
|
|
128
|
+
.then((user) => live && setState({ user, loading: false, error: null }))
|
|
129
|
+
.catch((e) => live && setState({ user: null, loading: false, error: e instanceof Error ? e : new Error(String(e)) }));
|
|
130
|
+
return () => {
|
|
131
|
+
live = false;
|
|
132
|
+
};
|
|
133
|
+
}, [token, apiBaseUrl, sample]);
|
|
134
|
+
return state;
|
|
135
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faclon-labs/app-sdk — IOSense sign-in and plant data for React apps.
|
|
3
|
+
*
|
|
4
|
+
* Wrap the app in `<IOSenseApp>`: it takes the session IOSense hands over (the launchpad's SSO
|
|
5
|
+
* token, a bearer on the URL, the `.iosense.io` cookie) or shows a sign-in screen, and gives every
|
|
6
|
+
* component the viewer's session. Then read UNS topics with `useLatest` / `useSeries`, the viewer
|
|
7
|
+
* with `useUser`, and call IOSense's APIs with `request(url, { auth: 'iosense' })`.
|
|
8
|
+
*/
|
|
9
|
+
export { IOSenseApp, SampleBadge, type IOSenseAppProps } from './IOSenseApp.js';
|
|
10
|
+
export { useIOSense, SAMPLE_NOTICE, type IOSenseSession } from './session.js';
|
|
11
|
+
export { SignIn } from './SignIn.js';
|
|
12
|
+
export { useLatest, useSeries, useUser, type LatestOptions, type LatestResult, type SeriesOptions, type SeriesResult, type IOSenseUser, type UserResult } from './hooks.js';
|
|
13
|
+
export { fetchLatest, fetchSeries, isSampleTopic, type SeriesPoint } from './data.js';
|
|
14
|
+
export { request, useRequest, RequestError, isIOSenseHost, type RequestOptions, type UseRequestOptions, type UseRequestResult } from './request.js';
|
|
15
|
+
export { configure, getConfig, type IOSenseConfig } from './config.js';
|
|
16
|
+
export { adoptHostSession, exchangeSsoToken, getToken, setToken, clearToken, isSsoToken, isEmbedded } from './auth.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faclon-labs/app-sdk — IOSense sign-in and plant data for React apps.
|
|
3
|
+
*
|
|
4
|
+
* Wrap the app in `<IOSenseApp>`: it takes the session IOSense hands over (the launchpad's SSO
|
|
5
|
+
* token, a bearer on the URL, the `.iosense.io` cookie) or shows a sign-in screen, and gives every
|
|
6
|
+
* component the viewer's session. Then read UNS topics with `useLatest` / `useSeries`, the viewer
|
|
7
|
+
* with `useUser`, and call IOSense's APIs with `request(url, { auth: 'iosense' })`.
|
|
8
|
+
*/
|
|
9
|
+
export { IOSenseApp, SampleBadge } from './IOSenseApp.js';
|
|
10
|
+
export { useIOSense, SAMPLE_NOTICE } from './session.js';
|
|
11
|
+
export { SignIn } from './SignIn.js';
|
|
12
|
+
export { useLatest, useSeries, useUser } from './hooks.js';
|
|
13
|
+
export { fetchLatest, fetchSeries, isSampleTopic } from './data.js';
|
|
14
|
+
export { request, useRequest, RequestError, isIOSenseHost } from './request.js';
|
|
15
|
+
export { configure, getConfig } from './config.js';
|
|
16
|
+
export { adoptHostSession, exchangeSsoToken, getToken, setToken, clearToken, isSsoToken, isEmbedded } from './auth.js';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app's own requests: any API, from the app's code.
|
|
3
|
+
*
|
|
4
|
+
* Plant data comes through `useLatest` and the widgets, but an app is more than plant data — a
|
|
5
|
+
* maintenance system, an ERP, a weather service, the app's own backend. `request` is `fetch` with
|
|
6
|
+
* the two things every such call wants: the body as JSON in and out, and an error when the server
|
|
7
|
+
* says no. `useRequest` is the same as a hook, with loading and error states, so a screen that reads
|
|
8
|
+
* an API is three lines rather than thirty. Plain `fetch` is allowed too; these are conveniences.
|
|
9
|
+
*
|
|
10
|
+
* **The login token goes only where it belongs.** `auth: 'iosense'` adds the viewer's IOSense token
|
|
11
|
+
* — needed for IOSense's own APIs — and is refused for any other host, so an app cannot be talked
|
|
12
|
+
* into sending the token somewhere else. Everything else is the browser's ordinary rules: a
|
|
13
|
+
* third-party API answers a page only if its CORS allows this origin (the Studio's while previewing,
|
|
14
|
+
* the app's own once deployed).
|
|
15
|
+
*/
|
|
16
|
+
export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
|
17
|
+
/** Sent as JSON, with the content type set. A string or FormData goes as it is. */
|
|
18
|
+
body?: unknown;
|
|
19
|
+
/** `'iosense'`: add the viewer's IOSense token. Refused for a host that is not IOSense's. */
|
|
20
|
+
auth?: 'iosense';
|
|
21
|
+
}
|
|
22
|
+
export declare class RequestError extends Error {
|
|
23
|
+
status: number;
|
|
24
|
+
/** What the server sent back, parsed as JSON when it was JSON. */
|
|
25
|
+
body: unknown;
|
|
26
|
+
constructor(message: string, status: number, body: unknown);
|
|
27
|
+
}
|
|
28
|
+
/** Where the viewer's token may go: IOSense's API host, and IOSense's own domains. */
|
|
29
|
+
export declare function isIOSenseHost(url: string, apiBaseUrl: string): boolean;
|
|
30
|
+
export declare function requestWith(token: string | null, apiBaseUrl: string, url: string, options?: RequestOptions): Promise<unknown>;
|
|
31
|
+
/** One request. Resolves with the parsed body; throws `RequestError` on a non-2xx answer. */
|
|
32
|
+
export declare function request<T = unknown>(url: string, options?: RequestOptions): Promise<T>;
|
|
33
|
+
export declare function publishSession(session: {
|
|
34
|
+
token: string | null;
|
|
35
|
+
apiBaseUrl: string;
|
|
36
|
+
}): void;
|
|
37
|
+
export interface UseRequestResult<T> {
|
|
38
|
+
data: T | undefined;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
error: Error | null;
|
|
41
|
+
/** Ask again now. */
|
|
42
|
+
refresh: () => void;
|
|
43
|
+
}
|
|
44
|
+
export interface UseRequestOptions extends RequestOptions {
|
|
45
|
+
/** Ask again every so often. Default: once. */
|
|
46
|
+
refreshMs?: number;
|
|
47
|
+
/** False: do not ask yet (the URL is not ready, a form is not filled in). */
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A request as a hook: asked when the component mounts, again when the URL changes.
|
|
52
|
+
*
|
|
53
|
+
* Options are compared as JSON, so `useRequest(url, { body: { mill } })` asks again when `mill`
|
|
54
|
+
* changes and not on every render.
|
|
55
|
+
*/
|
|
56
|
+
export declare function useRequest<T = unknown>(url: string | null | undefined, options?: UseRequestOptions): UseRequestResult<T>;
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useIOSense } from './session.js';
|
|
3
|
+
export class RequestError extends Error {
|
|
4
|
+
constructor(message, status, body) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.body = body;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Where the viewer's token may go: IOSense's API host, and IOSense's own domains. */
|
|
11
|
+
export function isIOSenseHost(url, apiBaseUrl) {
|
|
12
|
+
try {
|
|
13
|
+
const host = new URL(url, typeof window === 'undefined' ? 'http://localhost/' : window.location.href).hostname.toLowerCase();
|
|
14
|
+
const api = new URL(apiBaseUrl).hostname.toLowerCase();
|
|
15
|
+
return host === api || host === 'iosense.io' || host.endsWith('.iosense.io');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function parse(res) {
|
|
22
|
+
const type = res.headers.get('content-type') ?? '';
|
|
23
|
+
if (res.status === 204)
|
|
24
|
+
return null;
|
|
25
|
+
if (type.includes('application/json'))
|
|
26
|
+
return res.json().catch(() => null);
|
|
27
|
+
return res.text();
|
|
28
|
+
}
|
|
29
|
+
export async function requestWith(token, apiBaseUrl, url, options = {}) {
|
|
30
|
+
const { body, auth, headers, ...init } = options;
|
|
31
|
+
const h = new Headers(headers);
|
|
32
|
+
if (auth === 'iosense') {
|
|
33
|
+
if (!isIOSenseHost(url, apiBaseUrl))
|
|
34
|
+
throw new RequestError(`auth: 'iosense' is only for IOSense's own APIs, not ${url}`, 0, null);
|
|
35
|
+
if (token)
|
|
36
|
+
h.set('Authorization', `Bearer ${token}`);
|
|
37
|
+
}
|
|
38
|
+
let payload;
|
|
39
|
+
if (body !== undefined && body !== null) {
|
|
40
|
+
if (typeof body === 'string') {
|
|
41
|
+
// Already-encoded JSON is common (`body: JSON.stringify(x)`): send it as JSON, not as text.
|
|
42
|
+
payload = body;
|
|
43
|
+
if (!h.has('Content-Type')) {
|
|
44
|
+
try {
|
|
45
|
+
JSON.parse(body);
|
|
46
|
+
h.set('Content-Type', 'application/json');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
h.set('Content-Type', 'text/plain;charset=UTF-8');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams)
|
|
54
|
+
payload = body;
|
|
55
|
+
else {
|
|
56
|
+
payload = JSON.stringify(body);
|
|
57
|
+
if (!h.has('Content-Type'))
|
|
58
|
+
h.set('Content-Type', 'application/json');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!h.has('Accept'))
|
|
62
|
+
h.set('Accept', 'application/json, text/plain;q=0.9, */*;q=0.8');
|
|
63
|
+
const res = await fetch(url, { ...init, method: init.method ?? (payload === undefined ? 'GET' : 'POST'), headers: h, body: payload });
|
|
64
|
+
const data = await parse(res);
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const said = typeof data === 'object' && data && typeof data.message === 'string' ? data.message : '';
|
|
67
|
+
throw new RequestError(said || `${url} answered ${res.status}`, res.status, data);
|
|
68
|
+
}
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
/** One request. Resolves with the parsed body; throws `RequestError` on a non-2xx answer. */
|
|
72
|
+
export function request(url, options) {
|
|
73
|
+
return requestWith(readSession().token, readSession().apiBaseUrl, url, options);
|
|
74
|
+
}
|
|
75
|
+
// `request` is called from event handlers, outside React, so the session is read from the one the
|
|
76
|
+
// nearest `IOSenseApp` published rather than from a hook.
|
|
77
|
+
let current = { token: null, apiBaseUrl: '' };
|
|
78
|
+
export function publishSession(session) {
|
|
79
|
+
current = session;
|
|
80
|
+
}
|
|
81
|
+
const readSession = () => current;
|
|
82
|
+
/**
|
|
83
|
+
* A request as a hook: asked when the component mounts, again when the URL changes.
|
|
84
|
+
*
|
|
85
|
+
* Options are compared as JSON, so `useRequest(url, { body: { mill } })` asks again when `mill`
|
|
86
|
+
* changes and not on every render.
|
|
87
|
+
*/
|
|
88
|
+
export function useRequest(url, options = {}) {
|
|
89
|
+
const { token, apiBaseUrl } = useIOSense();
|
|
90
|
+
const { refreshMs = 0, enabled = true, ...rest } = options;
|
|
91
|
+
const optionsKey = JSON.stringify(rest);
|
|
92
|
+
const [data, setData] = useState();
|
|
93
|
+
const [loading, setLoading] = useState(Boolean(url) && enabled);
|
|
94
|
+
const [error, setError] = useState(null);
|
|
95
|
+
const generation = useRef(0);
|
|
96
|
+
const ask = useCallback(() => {
|
|
97
|
+
if (!url || !enabled)
|
|
98
|
+
return;
|
|
99
|
+
const mine = ++generation.current;
|
|
100
|
+
setLoading(true);
|
|
101
|
+
requestWith(token, apiBaseUrl, url, JSON.parse(optionsKey)).then((result) => {
|
|
102
|
+
if (mine !== generation.current)
|
|
103
|
+
return;
|
|
104
|
+
setData(result);
|
|
105
|
+
setError(null);
|
|
106
|
+
setLoading(false);
|
|
107
|
+
}, (err) => {
|
|
108
|
+
if (mine !== generation.current)
|
|
109
|
+
return;
|
|
110
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
111
|
+
setLoading(false);
|
|
112
|
+
});
|
|
113
|
+
}, [url, enabled, token, apiBaseUrl, optionsKey]);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
ask();
|
|
116
|
+
if (!refreshMs || !url || !enabled)
|
|
117
|
+
return;
|
|
118
|
+
const timer = setInterval(ask, refreshMs);
|
|
119
|
+
return () => clearInterval(timer);
|
|
120
|
+
}, [ask, refreshMs, url, enabled]);
|
|
121
|
+
return { data, loading, error, refresh: ask };
|
|
122
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** What every part of an app can ask about the session it runs in. */
|
|
2
|
+
export interface IOSenseSession {
|
|
3
|
+
/** The app's display name. */
|
|
4
|
+
appName: string;
|
|
5
|
+
/** The signed-in viewer's bearer, or null when nobody is signed in. */
|
|
6
|
+
token: string | null;
|
|
7
|
+
/** IOSense's API host. */
|
|
8
|
+
apiBaseUrl: string;
|
|
9
|
+
/** True when the app shows generated sample data rather than the plant's (no session, or asked for). */
|
|
10
|
+
sample: boolean;
|
|
11
|
+
/** One sentence for a badge, so every app words it the same way. */
|
|
12
|
+
sampleNotice: string;
|
|
13
|
+
/** Forget the session in this browser and show the sign-in screen again. */
|
|
14
|
+
signOut: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare const SAMPLE_NOTICE = "Sample data \u2014 not live plant readings";
|
|
17
|
+
export declare const SessionContext: import("react").Context<IOSenseSession | null>;
|
|
18
|
+
/** The session of the nearest `<IOSenseApp>`. Throws outside one. */
|
|
19
|
+
export declare function useIOSense(): IOSenseSession;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
export const SAMPLE_NOTICE = 'Sample data — not live plant readings';
|
|
3
|
+
export const SessionContext = createContext(null);
|
|
4
|
+
/** The session of the nearest `<IOSenseApp>`. Throws outside one. */
|
|
5
|
+
export function useIOSense() {
|
|
6
|
+
const session = useContext(SessionContext);
|
|
7
|
+
if (!session)
|
|
8
|
+
throw new Error('useIOSense: wrap the app in <IOSenseApp>.');
|
|
9
|
+
return session;
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@faclon-labs/app-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "IOSense sign-in and plant data for React apps: the launchpad SSO session, UNS latest values and series, the signed-in user, and requests to IOSense's APIs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"iosense",
|
|
25
|
+
"faclon",
|
|
26
|
+
"uns",
|
|
27
|
+
"react",
|
|
28
|
+
"sso"
|
|
29
|
+
],
|
|
30
|
+
"license": "UNLICENSED",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf dist && tsc -p tsconfig.json"
|
|
36
|
+
}
|
|
37
|
+
}
|